633 lines
24 KiB
Python
633 lines
24 KiB
Python
"""PromptState v2 — structured representation of a natlang prompt.
|
|
|
|
The /ai/patch render flow keeps this dataclass as the source of truth
|
|
between turns. node_prompt in the editor is the rendered prose VIEW;
|
|
PromptState is the model.
|
|
|
|
v2 schema rationale (replaces v1's disjoint cleared_slots / extra_phrases /
|
|
kept_slot_filter fields with a single per-slot SlotState map):
|
|
|
|
- The four ad-hoc helpers (`apply_reverse_displacement`,
|
|
`_ensure_active_slots_mentioned`, `_filter_outfit_extras`,
|
|
`scrub_displaced_modifier_phrases`) all existed because v1 split
|
|
each slot's truth across multiple fields. A single typed SlotState
|
|
per slot collapses those reconciliation seams.
|
|
- `mode` is gone. "stripped" is emergent (non-kept slots are
|
|
`cleared(by_modifier="strip")`), and "named" vs "custom" is
|
|
derivable from `bio_outfit_id`.
|
|
- `pose.descriptive_facts` separates carry-over user posture facts
|
|
(`["sitting", "legs up"]`) from `pose_modifiers` (canonical
|
|
pose-domain slot_modifiers like `presenting_foot`). Both concepts
|
|
were collapsed into v1's single `extra_phrases` and leaked into
|
|
each other.
|
|
|
|
Frontend persists this in `node.properties.pcrPromptState` (JSON), passes
|
|
it in /ai/patch body, and stores the returned updated state on Apply.
|
|
|
|
This file is Phase A of the vibrant-rendering-loom plan: data + (de)serialization
|
|
+ v1→v2 migration shim + v1 backward-compat property shims.
|
|
|
|
The compat shims (`slots`, `cleared_slots`, `kept_slot_filter`,
|
|
`extra_phrases`, `mode`) are attached to OutfitState AFTER the dataclass
|
|
decorator runs, so they don't clobber InitVar defaults during class-body
|
|
evaluation. Removed in Phase G after callers migrate to slot_states-native
|
|
access.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from dataclasses import dataclass, field, asdict, InitVar
|
|
from typing import Any, Optional
|
|
|
|
|
|
# Defensive strip for natlang fields. Curated DB rows or prior-turn
|
|
# persisted states sometimes carry a leading `// Section:` line in the
|
|
# prose body; the render flow emits its own header so the duplicate
|
|
# would otherwise stack.
|
|
_LEADING_SECTION_HEADER_RE = re.compile(r"^\s*//\s*[A-Za-z][^\n]*\n+")
|
|
|
|
|
|
def _strip_leading_section_header(text: str) -> str:
|
|
if not text:
|
|
return text
|
|
return _LEADING_SECTION_HEADER_RE.sub("", text).strip()
|
|
|
|
|
|
# ── slot taxonomy ──────────────────────────────────────────────────
|
|
SLOT_NAMES: tuple[str, ...] = (
|
|
"tops", "bottoms", "dresses",
|
|
"headwear", "footwear", "legwear",
|
|
"handwear", "lingerie", "swimwear",
|
|
"neckwear", "accessories", "modifiers",
|
|
)
|
|
|
|
SLOT_STATE_FILLED = "filled"
|
|
SLOT_STATE_CLEARED = "cleared"
|
|
|
|
ORIGIN_BIO = "bio"
|
|
ORIGIN_USER = "user"
|
|
ORIGIN_SWAP = "swap"
|
|
ORIGIN_NONE = ""
|
|
|
|
# v1 mode constants, kept for compat with callers that still import them.
|
|
OUTFIT_MODE_NAMED = "named"
|
|
OUTFIT_MODE_STRIPPED = "stripped"
|
|
OUTFIT_MODE_CUSTOM = "custom"
|
|
|
|
# Static seed list used during v1→v2 migration when DB classification isn't
|
|
# available. Runtime classification consults the DB.
|
|
_KNOWN_OUTFIT_MODIFIERS: frozenset[str] = frozenset({
|
|
"barefoot", "topless", "bottomless", "nude", "naked",
|
|
"fully_clothed", "exposed_chest", "exposed_breasts",
|
|
"no_bra", "no_panties", "underwear_only", "lingerie_only",
|
|
"stripped", "undressed",
|
|
})
|
|
|
|
_KNOWN_POSE_MODIFIERS: frozenset[str] = frozenset({
|
|
"presenting_foot", "presenting_feet",
|
|
"presenting_chest", "presenting_breasts",
|
|
"presenting_pussy",
|
|
"spreading_legs", "spread_legs",
|
|
"kneeling_pose", "lying_pose",
|
|
})
|
|
|
|
|
|
# ── dataclasses ────────────────────────────────────────────────────
|
|
|
|
@dataclass
|
|
class SlotState:
|
|
"""Per-slot truth. One per slot in OutfitState.slot_states.
|
|
|
|
state="filled" → item populated, optionally color, origin tracks where
|
|
it came from. state="cleared" → by_modifier names the canonical that
|
|
cleared it (e.g. "barefoot"), or "strip" for kept_slot_filter strips,
|
|
or "" if it was simply never set."""
|
|
state: str = SLOT_STATE_CLEARED
|
|
item: str = ""
|
|
color: str = ""
|
|
origin: str = ORIGIN_NONE
|
|
by_modifier: str = ""
|
|
bio_sentence: str = ""
|
|
|
|
|
|
def _empty_slot_states() -> dict[str, SlotState]:
|
|
return {name: SlotState() for name in SLOT_NAMES}
|
|
|
|
|
|
@dataclass
|
|
class OutfitState:
|
|
"""The character's currently-worn outfit, expressed as a per-slot map.
|
|
|
|
No `mode` field: "stripped" is emergent from cleared(by_modifier="strip")
|
|
states; "named" vs "custom" is derivable from bio_outfit_id presence.
|
|
|
|
The v1 compat shims (`slots`, `cleared_slots`, `kept_slot_filter`,
|
|
`extra_phrases`, `mode`) are attached as @properties AFTER this class
|
|
is decorated — see _attach_outfit_compat_shims at module bottom."""
|
|
name: str = ""
|
|
bio_outfit_id: Optional[int] = None
|
|
natlang_anchor: str = ""
|
|
slot_states: dict[str, SlotState] = field(default_factory=_empty_slot_states)
|
|
active_modifiers: list[str] = field(default_factory=list)
|
|
user_extra_phrases: list[str] = field(default_factory=list)
|
|
# When the outfit was borrowed from another character (e.g. user
|
|
# said `cammy white in chun-li's outfit`), source_display names
|
|
# the bio that owns it. Drives the `// Outfit: <Name> from
|
|
# Character: <Source>` header so readers see the outfit's true
|
|
# origin, not the subject. Empty when the outfit is the subject's own.
|
|
source_display: str = ""
|
|
# InitVars accept v1 constructor kwargs and route them through the
|
|
# property setters in __post_init__. None of these become instance fields.
|
|
slots: InitVar[Optional[list[dict]]] = None
|
|
cleared_slots: InitVar[Optional[list[str]]] = None
|
|
kept_slot_filter: InitVar[Optional[list[str]]] = None
|
|
extra_phrases: InitVar[Optional[list[str]]] = None
|
|
mode: InitVar[Optional[str]] = None
|
|
|
|
def __post_init__(self,
|
|
slots: Optional[list[dict]],
|
|
cleared_slots: Optional[list[str]],
|
|
kept_slot_filter: Optional[list[str]],
|
|
extra_phrases: Optional[list[str]],
|
|
mode: Optional[str]):
|
|
for n in SLOT_NAMES:
|
|
if n not in self.slot_states:
|
|
self.slot_states[n] = SlotState()
|
|
del mode # advisory in v2; silently accepted for compat
|
|
# Order matters: extras first (to populate active_modifiers for clear
|
|
# attribution), then slot fills, then clear declarations, then strip.
|
|
if extra_phrases is not None:
|
|
_outfit_set_extra_phrases(self, extra_phrases)
|
|
if slots is not None:
|
|
_outfit_set_slots(self, slots)
|
|
if cleared_slots is not None:
|
|
_outfit_set_cleared_slots(self, cleared_slots)
|
|
if kept_slot_filter is not None:
|
|
_outfit_set_kept_slot_filter(self, kept_slot_filter)
|
|
|
|
|
|
@dataclass
|
|
class PoseState:
|
|
"""The character's pose. descriptive_facts holds user-supplied posture
|
|
facts that should carry across turns ("sitting", "legs up"). They
|
|
are separate from pose_modifiers (canonical pose-domain slot_modifiers
|
|
like "presenting_foot") so the two cannot leak into each other —
|
|
the cause of v1's `_filter_outfit_extras` leak.
|
|
|
|
`extra_phrases` v1 compat property attached after class decoration."""
|
|
name: str = ""
|
|
bio_pose_id: Optional[int] = None
|
|
natlang_anchor: str = ""
|
|
is_signature: bool = False
|
|
descriptive_facts: list[str] = field(default_factory=list)
|
|
pose_modifiers: list[str] = field(default_factory=list)
|
|
# When the pose was borrowed from another character (`tifa lockhart
|
|
# in cammy's victory pose`), source_display names the bio that owns
|
|
# it. Drives the `// Pose: <Name> ... from Character: <Source>`
|
|
# header. Empty when the pose belongs to the subject character.
|
|
source_display: str = ""
|
|
extra_phrases: InitVar[Optional[list[str]]] = None
|
|
|
|
def __post_init__(self, extra_phrases: Optional[list[str]]):
|
|
if extra_phrases is not None:
|
|
_pose_set_extra_phrases(self, extra_phrases)
|
|
|
|
|
|
@dataclass
|
|
class CharacterState:
|
|
tag: str
|
|
display: str
|
|
series: str = ""
|
|
base_natlang: str = ""
|
|
base_tags: str = ""
|
|
outfit: OutfitState = field(default_factory=OutfitState)
|
|
pose: PoseState = field(default_factory=PoseState)
|
|
|
|
|
|
@dataclass
|
|
class StyleState:
|
|
template_id: str = ""
|
|
name: str = ""
|
|
|
|
|
|
@dataclass
|
|
class PromptState:
|
|
"""v2 structured truth behind a natlang prompt. Persisted in
|
|
node.properties.pcrPromptState and passed through /ai/patch."""
|
|
characters: list[CharacterState] = field(default_factory=list)
|
|
expression: str = ""
|
|
setting: str = ""
|
|
style: Optional[StyleState] = None
|
|
schema_version: int = 2
|
|
|
|
def to_dict(self) -> dict:
|
|
return asdict(self)
|
|
|
|
def to_json(self) -> str:
|
|
return json.dumps(self.to_dict(), separators=(",", ":"), ensure_ascii=False)
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: dict | None) -> "PromptState":
|
|
"""Deserialize. v1 states are detected by the presence of
|
|
outfit-level `cleared_slots`, `extra_phrases`, or `kept_slot_filter`
|
|
keys, and migrated to v2 in-place."""
|
|
if not data or not isinstance(data, dict):
|
|
return cls()
|
|
chars: list[CharacterState] = []
|
|
for c in (data.get("characters") or []):
|
|
if not isinstance(c, dict) or not c.get("tag"):
|
|
continue
|
|
outfit_d = c.get("outfit") or {}
|
|
pose_d = c.get("pose") or {}
|
|
chars.append(CharacterState(
|
|
tag=c.get("tag", ""),
|
|
display=c.get("display", ""),
|
|
series=c.get("series", ""),
|
|
base_natlang=_strip_leading_section_header(c.get("base_natlang", "")),
|
|
base_tags=c.get("base_tags", ""),
|
|
outfit=_outfit_from_dict(outfit_d),
|
|
pose=_pose_from_dict(pose_d),
|
|
))
|
|
style_d = data.get("style")
|
|
style = None
|
|
if isinstance(style_d, dict) and (style_d.get("template_id") or style_d.get("name")):
|
|
style = StyleState(
|
|
template_id=style_d.get("template_id", ""),
|
|
name=style_d.get("name", ""),
|
|
)
|
|
return cls(
|
|
characters=chars,
|
|
expression=data.get("expression", ""),
|
|
setting=data.get("setting", ""),
|
|
style=style,
|
|
schema_version=2,
|
|
)
|
|
|
|
@classmethod
|
|
def from_json(cls, raw: str | None) -> "PromptState":
|
|
if not raw:
|
|
return cls()
|
|
try:
|
|
return cls.from_dict(json.loads(raw))
|
|
except (json.JSONDecodeError, TypeError, ValueError):
|
|
return cls()
|
|
|
|
def is_empty(self) -> bool:
|
|
return (
|
|
not self.characters
|
|
and not self.expression
|
|
and not self.setting
|
|
and self.style is None
|
|
)
|
|
|
|
def primary_character(self) -> Optional[CharacterState]:
|
|
return self.characters[0] if self.characters else None
|
|
|
|
|
|
# ── dict → dataclass helpers (with v1 migration) ────────────────────
|
|
|
|
def _is_v1_outfit(d: dict) -> bool:
|
|
return any(k in d for k in ("cleared_slots", "extra_phrases", "kept_slot_filter"))
|
|
|
|
|
|
def _slot_state_from_dict(d: Any) -> SlotState:
|
|
if not isinstance(d, dict):
|
|
return SlotState()
|
|
return SlotState(
|
|
state=d.get("state", SLOT_STATE_CLEARED),
|
|
item=d.get("item", ""),
|
|
color=d.get("color", ""),
|
|
origin=d.get("origin", ORIGIN_NONE),
|
|
by_modifier=d.get("by_modifier", ""),
|
|
bio_sentence=d.get("bio_sentence", ""),
|
|
)
|
|
|
|
|
|
def _outfit_from_dict(d: dict) -> OutfitState:
|
|
if not isinstance(d, dict):
|
|
return OutfitState()
|
|
if _is_v1_outfit(d):
|
|
return _migrate_outfit_v1(d)
|
|
raw_states = d.get("slot_states") or {}
|
|
slot_states: dict[str, SlotState] = {}
|
|
for n in SLOT_NAMES:
|
|
slot_states[n] = _slot_state_from_dict(raw_states.get(n))
|
|
return OutfitState(
|
|
name=d.get("name", ""),
|
|
bio_outfit_id=d.get("bio_outfit_id"),
|
|
natlang_anchor=_strip_leading_section_header(d.get("natlang_anchor", "")),
|
|
slot_states=slot_states,
|
|
active_modifiers=list(d.get("active_modifiers") or []),
|
|
user_extra_phrases=list(d.get("user_extra_phrases") or []),
|
|
)
|
|
|
|
|
|
def _pose_from_dict(d: dict) -> PoseState:
|
|
if not isinstance(d, dict):
|
|
return PoseState()
|
|
if "extra_phrases" in d and "descriptive_facts" not in d:
|
|
return _migrate_pose_v1(d)
|
|
return PoseState(
|
|
name=d.get("name", ""),
|
|
bio_pose_id=d.get("bio_pose_id"),
|
|
natlang_anchor=_strip_leading_section_header(d.get("natlang_anchor", "")),
|
|
is_signature=bool(d.get("is_signature")),
|
|
descriptive_facts=list(d.get("descriptive_facts") or []),
|
|
pose_modifiers=list(d.get("pose_modifiers") or []),
|
|
)
|
|
|
|
|
|
# ── v1 → v2 migration ──────────────────────────────────────────────
|
|
|
|
def _migrate_outfit_v1(d: dict) -> OutfitState:
|
|
"""Convert a v1 OutfitState dict to v2.
|
|
|
|
v1 fields → v2 mapping:
|
|
- slots: list[{slot_type, item, color, ...}]
|
|
→ slot_states[slot_type] = filled(item, color, origin="bio", bio_sentence)
|
|
- cleared_slots: list[str]
|
|
→ slot_states[s] = cleared(by_modifier=<inferred from extras>)
|
|
- kept_slot_filter: list[str] | None
|
|
→ all non-kept slots become cleared(by_modifier="strip")
|
|
- extra_phrases: list[str]
|
|
→ outfit-domain modifier canonicals → active_modifiers
|
|
pose-domain modifier canonicals → DROPPED (v1 leak fix)
|
|
free-form prose → user_extra_phrases
|
|
"""
|
|
slot_states = _empty_slot_states()
|
|
|
|
for s in (d.get("slots") or []):
|
|
if not isinstance(s, dict):
|
|
continue
|
|
slot_type = (s.get("slot_type") or "").strip().lower()
|
|
if slot_type not in SLOT_NAMES:
|
|
continue
|
|
item = (s.get("item") or "").strip()
|
|
if not item:
|
|
continue
|
|
slot_states[slot_type] = SlotState(
|
|
state=SLOT_STATE_FILLED,
|
|
item=item,
|
|
color=(s.get("color") or "").strip(),
|
|
origin=ORIGIN_BIO,
|
|
bio_sentence=(s.get("bio_sentence") or "").strip(),
|
|
)
|
|
|
|
active_modifiers: list[str] = []
|
|
user_extras: list[str] = []
|
|
for raw in (d.get("extra_phrases") or []):
|
|
if not isinstance(raw, str):
|
|
continue
|
|
canon = raw.strip().lower().replace(" ", "_").replace("-", "_")
|
|
if not canon:
|
|
continue
|
|
if canon in _KNOWN_OUTFIT_MODIFIERS:
|
|
if canon not in active_modifiers:
|
|
active_modifiers.append(canon)
|
|
elif canon in _KNOWN_POSE_MODIFIERS:
|
|
continue
|
|
else:
|
|
user_extras.append(raw.strip())
|
|
|
|
for s in (d.get("cleared_slots") or []):
|
|
slot_name = (s or "").strip().lower()
|
|
if slot_name not in SLOT_NAMES:
|
|
continue
|
|
attribution = ""
|
|
for mod in active_modifiers:
|
|
if mod == "barefoot" and slot_name in ("footwear", "legwear"):
|
|
attribution = mod
|
|
break
|
|
if mod in ("topless", "exposed_chest", "exposed_breasts", "no_bra") and slot_name in ("tops", "lingerie"):
|
|
attribution = mod
|
|
break
|
|
if mod in ("bottomless", "no_panties") and slot_name in ("bottoms", "lingerie"):
|
|
attribution = mod
|
|
break
|
|
if mod in ("nude", "naked", "stripped", "undressed"):
|
|
attribution = mod
|
|
break
|
|
slot_states[slot_name] = SlotState(
|
|
state=SLOT_STATE_CLEARED,
|
|
by_modifier=attribution,
|
|
)
|
|
|
|
kept = d.get("kept_slot_filter")
|
|
if isinstance(kept, list):
|
|
kept_set = {(k or "").strip().lower() for k in kept}
|
|
for slot_name in SLOT_NAMES:
|
|
if slot_name in kept_set:
|
|
continue
|
|
cur = slot_states[slot_name]
|
|
if cur.state == SLOT_STATE_FILLED:
|
|
continue
|
|
slot_states[slot_name] = SlotState(
|
|
state=SLOT_STATE_CLEARED,
|
|
by_modifier=cur.by_modifier or "strip",
|
|
)
|
|
|
|
return OutfitState(
|
|
name=d.get("name", ""),
|
|
bio_outfit_id=d.get("bio_outfit_id"),
|
|
natlang_anchor=_strip_leading_section_header(d.get("natlang_anchor", "")),
|
|
slot_states=slot_states,
|
|
active_modifiers=active_modifiers,
|
|
user_extra_phrases=user_extras,
|
|
)
|
|
|
|
|
|
def _migrate_pose_v1(d: dict) -> PoseState:
|
|
descriptive_facts: list[str] = []
|
|
pose_modifiers: list[str] = []
|
|
for raw in (d.get("extra_phrases") or []):
|
|
if not isinstance(raw, str):
|
|
continue
|
|
phrase = raw.strip()
|
|
if not phrase:
|
|
continue
|
|
canon = phrase.lower().replace(" ", "_").replace("-", "_")
|
|
if canon in _KNOWN_POSE_MODIFIERS:
|
|
if canon not in pose_modifiers:
|
|
pose_modifiers.append(canon)
|
|
else:
|
|
descriptive_facts.append(phrase)
|
|
return PoseState(
|
|
name=d.get("name", ""),
|
|
bio_pose_id=d.get("bio_pose_id"),
|
|
natlang_anchor=_strip_leading_section_header(d.get("natlang_anchor", "")),
|
|
is_signature=bool(d.get("is_signature")),
|
|
descriptive_facts=descriptive_facts,
|
|
pose_modifiers=pose_modifiers,
|
|
)
|
|
|
|
|
|
# ── v1 backward-compat shims (attached as properties after class decoration) ──
|
|
|
|
def _outfit_get_slots(self: OutfitState) -> list[dict]:
|
|
out: list[dict] = []
|
|
for n in SLOT_NAMES:
|
|
s = self.slot_states[n]
|
|
if s.state != SLOT_STATE_FILLED or not s.item:
|
|
continue
|
|
row = {"slot_type": n, "item": s.item}
|
|
if s.color:
|
|
row["color"] = s.color
|
|
if s.bio_sentence:
|
|
row["bio_sentence"] = s.bio_sentence
|
|
out.append(row)
|
|
return out
|
|
|
|
|
|
def _outfit_set_slots(self: OutfitState, rows: Optional[list[dict]]) -> None:
|
|
new_filled: set[str] = set()
|
|
for row in (rows or []):
|
|
if not isinstance(row, dict):
|
|
continue
|
|
slot_type = (row.get("slot_type") or row.get("slot") or "").strip().lower()
|
|
if slot_type not in SLOT_NAMES:
|
|
continue
|
|
item = (row.get("item") or "").strip()
|
|
if not item:
|
|
continue
|
|
self.slot_states[slot_type] = SlotState(
|
|
state=SLOT_STATE_FILLED,
|
|
item=item,
|
|
color=(row.get("color") or "").strip(),
|
|
origin=row.get("origin") or self.slot_states[slot_type].origin or ORIGIN_BIO,
|
|
bio_sentence=(row.get("bio_sentence") or "").strip(),
|
|
)
|
|
new_filled.add(slot_type)
|
|
# Slots that were previously filled but aren't in the new list become
|
|
# absent (caller is replacing the active fill set; they didn't ask to
|
|
# clear-with-modifier).
|
|
for n in SLOT_NAMES:
|
|
if n in new_filled:
|
|
continue
|
|
cur = self.slot_states[n]
|
|
if cur.state == SLOT_STATE_FILLED:
|
|
self.slot_states[n] = SlotState()
|
|
|
|
|
|
def _outfit_get_cleared_slots(self: OutfitState) -> list[str]:
|
|
return sorted(
|
|
n for n in SLOT_NAMES
|
|
if self.slot_states[n].state == SLOT_STATE_CLEARED
|
|
and self.slot_states[n].by_modifier
|
|
and self.slot_states[n].by_modifier != "strip"
|
|
)
|
|
|
|
|
|
def _outfit_set_cleared_slots(self: OutfitState, names: Optional[list[str]]) -> None:
|
|
"""v1 cleared_slots names slots cleared by a modifier. We always force a
|
|
non-empty by_modifier attribution so the readback list isn't empty."""
|
|
wanted = {(n or "").strip().lower() for n in (names or [])}
|
|
wanted.discard("")
|
|
fallback = self.active_modifiers[0] if self.active_modifiers else "legacy"
|
|
for n in SLOT_NAMES:
|
|
cur = self.slot_states[n]
|
|
if n in wanted:
|
|
if cur.state == SLOT_STATE_FILLED:
|
|
continue # explicit fills survive
|
|
attrib = cur.by_modifier if (cur.by_modifier and cur.by_modifier != "strip") else fallback
|
|
self.slot_states[n] = SlotState(state=SLOT_STATE_CLEARED, by_modifier=attrib)
|
|
else:
|
|
if cur.state == SLOT_STATE_CLEARED and cur.by_modifier and cur.by_modifier != "strip":
|
|
self.slot_states[n] = SlotState()
|
|
|
|
|
|
def _outfit_get_kept_slot_filter(self: OutfitState) -> Optional[list[str]]:
|
|
strip_active = any(self.slot_states[n].by_modifier == "strip" for n in SLOT_NAMES)
|
|
if not strip_active:
|
|
return None
|
|
return sorted(
|
|
n for n in SLOT_NAMES
|
|
if self.slot_states[n].state == SLOT_STATE_FILLED
|
|
or self.slot_states[n].by_modifier != "strip"
|
|
)
|
|
|
|
|
|
def _outfit_set_kept_slot_filter(self: OutfitState, names: Optional[list[str]]) -> None:
|
|
if names is None:
|
|
for n in SLOT_NAMES:
|
|
if self.slot_states[n].by_modifier == "strip":
|
|
self.slot_states[n] = SlotState()
|
|
return
|
|
kept = {(k or "").strip().lower() for k in names}
|
|
kept.discard("")
|
|
for n in SLOT_NAMES:
|
|
if n in kept:
|
|
continue
|
|
cur = self.slot_states[n]
|
|
if cur.state == SLOT_STATE_FILLED:
|
|
continue
|
|
self.slot_states[n] = SlotState(
|
|
state=SLOT_STATE_CLEARED,
|
|
by_modifier=cur.by_modifier or "strip",
|
|
)
|
|
|
|
|
|
def _outfit_get_extra_phrases(self: OutfitState) -> list[str]:
|
|
return list(self.active_modifiers) + list(self.user_extra_phrases)
|
|
|
|
|
|
def _outfit_set_extra_phrases(self: OutfitState, phrases: Optional[list[str]]) -> None:
|
|
active: list[str] = []
|
|
user: list[str] = []
|
|
for raw in (phrases or []):
|
|
if not isinstance(raw, str) or not raw.strip():
|
|
continue
|
|
canon = raw.strip().lower().replace(" ", "_").replace("-", "_")
|
|
if canon in _KNOWN_OUTFIT_MODIFIERS:
|
|
if canon not in active:
|
|
active.append(canon)
|
|
elif canon in _KNOWN_POSE_MODIFIERS:
|
|
continue
|
|
else:
|
|
user.append(raw.strip())
|
|
self.active_modifiers = active
|
|
self.user_extra_phrases = user
|
|
|
|
|
|
def _outfit_get_mode(self: OutfitState) -> str:
|
|
if any(self.slot_states[n].by_modifier == "strip" for n in SLOT_NAMES):
|
|
return OUTFIT_MODE_STRIPPED
|
|
if self.bio_outfit_id is not None:
|
|
return OUTFIT_MODE_NAMED
|
|
return OUTFIT_MODE_CUSTOM
|
|
|
|
|
|
def _outfit_set_mode(self: OutfitState, _value: str) -> None:
|
|
return # advisory only
|
|
|
|
|
|
def _pose_get_extra_phrases(self: PoseState) -> list[str]:
|
|
return list(self.descriptive_facts) + list(self.pose_modifiers)
|
|
|
|
|
|
def _pose_set_extra_phrases(self: PoseState, phrases: Optional[list[str]]) -> None:
|
|
descriptive: list[str] = []
|
|
modifiers: list[str] = []
|
|
for raw in (phrases or []):
|
|
if not isinstance(raw, str) or not raw.strip():
|
|
continue
|
|
canon = raw.strip().lower().replace(" ", "_").replace("-", "_")
|
|
if canon in _KNOWN_POSE_MODIFIERS:
|
|
if canon not in modifiers:
|
|
modifiers.append(canon)
|
|
else:
|
|
descriptive.append(raw.strip())
|
|
self.descriptive_facts = descriptive
|
|
self.pose_modifiers = modifiers
|
|
|
|
|
|
# Attach property descriptors AFTER @dataclass decoration. Doing it inside
|
|
# the class body would clobber the InitVar class attrs and break dataclass's
|
|
# default-value detection.
|
|
OutfitState.slots = property(_outfit_get_slots, _outfit_set_slots)
|
|
OutfitState.cleared_slots = property(_outfit_get_cleared_slots, _outfit_set_cleared_slots)
|
|
OutfitState.kept_slot_filter = property(_outfit_get_kept_slot_filter, _outfit_set_kept_slot_filter)
|
|
OutfitState.extra_phrases = property(_outfit_get_extra_phrases, _outfit_set_extra_phrases)
|
|
OutfitState.mode = property(_outfit_get_mode, _outfit_set_mode)
|
|
PoseState.extra_phrases = property(_pose_get_extra_phrases, _pose_set_extra_phrases)
|