local ADDON_NAME, RPAL = ...

-- ============================================================
-- ODYN Persona Living Layer
-- Voice register, profanity, TRP relationship flavour, injury-aware thoughts,
-- idle impulses and narrowly-scoped TRP Currently automation.
-- ============================================================

local VERSION = 10
local unpack = unpack or table.unpack
local WHITE = "Interface\\Buttons\\WHITE8X8"
local FONT = STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF"

local VOICES = {
    street={label="Street",description="Blunt street speech: clipped subjects, ain’t/gonna/wanna/kinda, rougher vocabulary and looser sentence rhythm."},
    casual={label="Casual",description="The default voice: natural modern inner speech, ordinary contractions and no deliberate social register."},
    refined={label="Refined",description="Careful, articulate phrasing: fuller sentences, more precise vocabulary and noticeably fewer slang constructions."},
    aristocratic={label="Aristocratic",description="Elaborate, self-possessed phrasing: longer clauses, rarer vocabulary and a deliberately cultivated, upper-class cadence."},
}
local VOICE_ORDER={"street","casual","refined","aristocratic"}

local PROFANITY = {
    none={label="None",chance=0},
    rare={label="Rare",chance=.14},
    casual={label="Occasional",chance=.36},
    frequent={label="Frequent",chance=.68},
    heavy={label="Heavy",chance=.92},
}
local PROFANITY_ORDER={"none","rare","casual","frequent","heavy"}
local PROFANITY_INDEX={none=1,rare=2,casual=3,frequent=4,heavy=5}

-- Urge text is third-person on purpose. It is visually distinct from thoughts
-- and describes a bodily habit/impulse rather than narrating dialogue for the player.
local IDLES = {
    smoke={label="Smoke a cigarette",group="Habits",currently="smoking a cigarette",urge={"feels like smoking a cigarette.","could really use a cigarette.","has the familiar urge to light a cigarette.","finds their thoughts drifting toward a cigarette."}},
    finger_fidget={label="Fidget with fingers",group="Hands",currently="fidgeting with their fingers",urge={"starts wanting something to do with their fingers.","feels their fingers getting restless.","has the urge to fidget with their hands.","catches the familiar need to keep their fingers busy."}},
    scratch={label="Scratch an itch",group="Body",currently="absently scratching an itch",urge={"feels an annoying little itch.","has the urge to scratch at their skin.","notices an itch they keep trying to ignore.","feels like scratching at a stubborn itch."}},
    tremor={label="Brief tremor",group="Body",currently="trying to still a faint tremor",urge={"notices a faint tremor running through their hands.","feels a small involuntary tremble.","has to steady a brief tremor.","feels their fingers shake for a moment."}},
    close_eyes={label="Close eyes for a while",group="Body",currently="resting with their eyes closed",urge={"feels like keeping their eyes closed for a moment.","wants to shut their eyes and tune everything out briefly.","feels the need to rest their eyes.","could use a few quiet seconds with their eyes closed."}},
    crack_knuckles={label="Crack knuckles",group="Joints",currently="cracking their knuckles",urge={"feels like cracking their knuckles.","has the urge to work the stiffness out of their knuckles.","flexes their fingers, wanting to crack the joints.","notices their knuckles begging to be cracked."}},
    crack_fingers={label="Crack fingers",group="Joints",currently="cracking their finger joints",urge={"feels stiffness in their fingers and wants to crack them.","has the urge to pop a few finger joints.","works their fingers, tempted to crack them.","feels like loosening their finger joints."}},
    crack_neck={label="Crack neck",group="Joints",currently="rolling and cracking their neck",urge={"feels like cracking their neck.","notices stiffness gathering in their neck.","has the urge to roll the tension out of their neck.","wants to loosen their neck with a quick crack."}},
    roll_shoulders={label="Roll shoulders",group="Joints",currently="rolling the tension from their shoulders",urge={"feels tension building in their shoulders.","wants to roll their shoulders loose.","notices their shoulders getting stiff.","has the urge to work some tension out of their shoulders."}},
    stretch={label="Stretch",group="Body",currently="taking a slow stretch",urge={"feels like stretching properly.","has the urge to straighten up and stretch.","notices their body asking for a stretch.","could use a long, slow stretch."}},
    shift_weight={label="Shift weight",group="Body",currently="shifting their weight from one foot to the other",urge={"starts feeling uncomfortable standing the same way.","has the urge to shift their weight.","finds one leg getting restless.","wants to change their stance for a moment."}},
    straighten_posture={label="Straighten posture",group="Body",currently="straightening their posture",urge={"notices their posture slipping.","feels like straightening their back.","catches themselves slouching a little.","has the urge to stand a little straighter."}},
    rub_neck={label="Rub neck",group="Body",currently="rubbing the back of their neck",urge={"feels like rubbing the tension from their neck.","notices a tight spot at the back of their neck.","has the urge to rub their neck.","feels tension settling at the base of their neck."}},
    flex_hand={label="Flex hand",group="Hands",currently="slowly flexing one hand",urge={"feels like flexing some stiffness out of their hand.","notices one hand getting tense.","has the urge to open and close their fingers a few times.","wants to work a little stiffness from their hand."}},
    drum_fingers={label="Drum fingers",group="Hands",currently="quietly drumming their fingers",urge={"feels their fingers wanting to drum against something.","has the urge to tap out a quiet rhythm.","catches their fingers looking for a surface to drum on.","wants to drum their fingertips for a moment."}},
    rub_hands={label="Rub hands together",group="Hands",currently="rubbing their hands together",urge={"feels like rubbing their hands together.","notices their palms wanting a little friction.","has the urge to work their hands together.","finds themselves wanting to rub warmth into their hands."}},
    tap_foot={label="Tap foot",group="Restless",currently="quietly tapping one foot",urge={"feels one foot starting to get restless.","has the urge to tap their foot.","catches a rhythm building in one foot.","wants to tap their foot without thinking about it."}},
    pace={label="Pace",group="Restless",currently="pacing a short line back and forth",urge={"feels like pacing for a minute.","has too much restless energy to stay completely still.","wants to walk a short line back and forth.","feels the familiar urge to pace."}},
    check_pockets={label="Check pockets",group="Habits",currently="checking their pockets by habit",urge={"has the sudden urge to check their pockets.","wonders for a second whether everything is still where it should be.","feels like patting down their pockets out of habit.","catches the familiar need to make sure nothing is missing."}},
    adjust_gloves={label="Adjust gloves",group="Clothing",currently="adjusting their gloves",urge={"feels like readjusting their gloves.","notices their gloves sitting wrong.","has the urge to tug their gloves back into place.","wants to settle their gloves more comfortably."}},
    fix_sleeve={label="Adjust sleeves",group="Clothing",currently="straightening a sleeve",urge={"notices a sleeve sitting wrong.","has the urge to straighten their sleeve.","feels like tugging their clothing back into place.","catches a small fold in their sleeve that bothers them."}},
    smooth_hair={label="Fix hair",group="Grooming",currently="smoothing their hair back into place",urge={"feels like fixing their hair.","notices a strand of hair bothering them.","has the urge to smooth their hair back.","catches themselves wanting to tidy their hair."}},
    wet_lips={label="Wet lips",group="Grooming",currently="briefly wetting their lips",urge={"notices their lips feeling dry.","has the urge to wet their lips.","feels a little dryness at their lips.","catches themselves wanting to wet their lips."}},
    bite_lip={label="Bite lip",group="Grooming",currently="absently worrying at their lower lip",urge={"has the urge to worry at their lower lip.","catches themselves wanting to bite their lip.","feels their teeth drift toward their lower lip by habit.","notices the familiar urge to bite at their lip."}},
    scan_room={label="Scan surroundings",group="Awareness",currently="quietly scanning their surroundings",urge={"feels the need to check their surroundings again.","has the urge to scan the room.","catches themselves wanting another look around.","feels their attention pulling toward the edges of the room."}},
    sigh={label="Sigh",group="Breath",currently="letting out a slow breath",urge={"feels a sigh building in their chest.","has the urge to let out a long breath.","could use one slow, quiet exhale.","feels like sighing and letting a little tension go."}},
    yawn={label="Yawn",group="Breath",currently="trying to stifle a yawn",urge={"feels a yawn coming on.","has to fight the urge to yawn.","feels their jaw wanting to open into a yawn.","notices tiredness tugging a yawn out of them."}},
}
local IDLE_ORDER={
    "smoke","finger_fidget","scratch","tremor","close_eyes","crack_knuckles","crack_fingers","crack_neck","roll_shoulders","stretch","shift_weight","straighten_posture","rub_neck","flex_hand","drum_fingers","rub_hands","tap_foot","pace","check_pockets","adjust_gloves","fix_sleeve","smooth_hair","wet_lips","bite_lip","scan_room","sigh","yawn"
}

RPAL.PERSONA_VOICE_PROFILES=VOICES
RPAL.PERSONA_VOICE_ORDER=VOICE_ORDER
RPAL.PERSONA_PROFANITY_LEVELS=PROFANITY
RPAL.PERSONA_PROFANITY_ORDER=PROFANITY_ORDER
RPAL.PERSONA_IDLE_ACTIVITIES=IDLES
RPAL.PERSONA_IDLE_ORDER=IDLE_ORDER

local function trim(s)return tostring(s or""):gsub("^%s+",""):gsub("%s+$","")end
local function clamp(v,a,b)v=tonumber(v)or a;if v<a then return a elseif v>b then return b end;return v end
local function copySet(src)local out={};for k,v in pairs(type(src)=="table" and src or{})do if v then out[tostring(k)]=true end end;return out end

local function ensureLiving()
    local p=RPAL.GetPersonalityData and RPAL.GetPersonalityData()
    if not p then return nil end
    p.living=type(p.living)=="table" and p.living or {}
    local d=p.living
    local previousVersion=math.floor(tonumber(d.version)or 0)
    d.version=VERSION
    if d.voice=="natural" or not VOICES[d.voice] then d.voice="casual" end
    if not PROFANITY[d.profanity] then d.profanity="none" end
    d.voiceLearning=type(d.voiceLearning)=="table" and d.voiceLearning or {}
    local vl=d.voiceLearning
    if vl.enabled==nil then vl.enabled=true end
    vl.samples=type(vl.samples)=="table" and vl.samples or {}
    vl.nextSampleSerial=math.max(0,math.floor(tonumber(vl.nextSampleSerial)or 0))
    for _,sample in ipairs(vl.samples)do
        if type(sample)=="table"then
            local serial=math.floor(tonumber(sample.serial)or 0)
            if serial<1 then vl.nextSampleSerial=vl.nextSampleSerial+1;serial=vl.nextSampleSerial;sample.serial=serial
            else vl.nextSampleSerial=math.max(vl.nextSampleSerial,serial)end
        end
    end
    vl.voiceEvidenceAfterSerial=math.max(0,math.floor(tonumber(vl.voiceEvidenceAfterSerial)or 0))
    vl.profanityEvidenceAfterSerial=math.max(0,math.floor(tonumber(vl.profanityEvidenceAfterSerial)or 0))
    vl.lastLearnedAt=tonumber(vl.lastLearnedAt) or 0
    vl.lastEffective=tostring(vl.lastEffective or "")
    vl.lastEffectiveAt=tonumber(vl.lastEffectiveAt) or 0
    vl.lastEffectiveProfanity=tostring(vl.lastEffectiveProfanity or "")
    vl.lastEffectiveProfanityAt=tonumber(vl.lastEffectiveProfanityAt) or 0
    vl.source="self_ic_speech_only"
    if previousVersion<8 and not vl.adaptiveV8MigratedAt then
        -- Discard stale legacy "effective" labels from earlier builds,
        -- then let retained self-authored samples earn the first adaptive read.
        vl.lastEffective=d.voice;vl.lastEffectiveProfanity=d.profanity
        vl.adaptiveV8MigratedAt=time and time()or 0
    end
    if previousVersion<9 and not vl.calibrationLockV9MigratedAt then
        -- Voice/Profanity are calibration baselines again. Existing calibrated
        -- characters keep their current baselines and Adaptive Voice is put
        -- back under ODYN's automatic control rather than left manually off.
        local calibrated=false
        if RPAL.IsPersonalityCalibrated then
            local ok,result=pcall(RPAL.IsPersonalityCalibrated)
            calibrated=ok and result==true
        end
        if calibrated then vl.enabled=true end
        vl.calibrationLockV9MigratedAt=time and time()or 0
    end
    d.idle=type(d.idle)=="table" and d.idle or {}
    if d.idle.enabled==nil then d.idle.enabled=true end
    if d.idle.writeCurrently==nil then d.idle.writeCurrently=false end
    d.idle.selected=type(d.idle.selected)=="table" and d.idle.selected or {}
    d.state=type(d.state)=="table" and d.state or {}
    d.state.lastIdleKey=tostring(d.state.lastIdleKey or"")
    d.state.nextIdleAt=tonumber(d.state.nextIdleAt) or 0
    d.state.lastRelationAt=tonumber(d.state.lastRelationAt) or 0
    d.state.lastInjuryAt=tonumber(d.state.lastInjuryAt) or 0
    d.state.recentThoughtKeys=type(d.state.recentThoughtKeys)=="table" and d.state.recentThoughtKeys or {}
    return d,p
end

function RPAL.GetPersonaLivingConfig()
    local d=ensureLiving();return d
end
local function livingCalibrationLocked()
    if not RPAL.IsPersonalityCalibrated then return false end
    local ok,result=pcall(RPAL.IsPersonalityCalibrated)
    return ok and result==true
end
RPAL.IsPersonaLivingCalibrationLocked=livingCalibrationLocked
function RPAL.SetPersonaVoiceProfile(value)
    local d=ensureLiving();value=tostring(value or""):lower();if not d or not VOICES[value]then return false end
    if livingCalibrationLocked()then return false,"calibrated_locked"end
    -- This is the calibration baseline. Once calibration is complete only
    -- sustained self-authored IC evidence may move the effective register.
    d.voice=value;d.voiceLearning.lastEffective=value;d.voiceLearning.lastEffectiveAt=time and time()or 0
    d.voiceLearning.voiceEvidenceAfterSerial=d.voiceLearning.nextSampleSerial
    if RPAL.RefreshPersonalityUI then RPAL.RefreshPersonalityUI()end;return true
end
function RPAL.SetPersonaAdaptiveVoiceEnabled(enabled)
    local d=ensureLiving();if not d then return false end
    if livingCalibrationLocked()then return false,"calibrated_locked"end
    d.voiceLearning.enabled=enabled and true or false
    if RPAL.RefreshPersonalityUI then RPAL.RefreshPersonalityUI()end
    return true
end

local VOICE_LEARN_WINDOW=45*86400
local VOICE_LEARN_MAX=180
local VOICE_RECENCY_HALF_LIFE=12*86400
local VOICE_ORDER_HALF_LIFE=54
local function countWords(text)
    local n=0;for _ in tostring(text or""):gmatch("[%a']+")do n=n+1 end;return n
end
local function countPlain(text,needle)
    local n=0;local start=1
    while true do local i,j=text:find(needle,start,true);if not i then break end;n=n+1;start=j+1 end
    return n
end
local function wordCount(text,word)
    local padded=" "..tostring(text or""):lower():gsub("[^%a']"," "):gsub("%s+"," ").." "
    return countPlain(padded," "..word.." ")
end
local function voiceSample(text)
    local raw=tostring(text or""):gsub("’","'"):gsub("‘","'")
    local low=raw:lower();local words=countWords(low)
    if words<2 then return nil end
    local slang=0
    local slangWords={"ain't","gonna","wanna","gotta","kinda","sorta","lemme","gimme","dunno","ya","yer","y'all","nah","nope","yep","reckon"}
    for _,w in ipairs(slangWords)do slang=slang+wordCount(low,w) end
    slang=slang+countPlain(low,"'em")+countPlain(low,"outta")+countPlain(low,"lotta")
    local dropG=0;for _ in low:gmatch("[%a]+in'")do dropG=dropG+1 end
    local ingEligible=0
    for w in low:gmatch("[%a']+")do
        if w:match("ing$") or w:match("in'$") then ingEligible=ingEligible+1 end
    end
    local formal=0
    local formalWords={"perhaps","indeed","however","nevertheless","certainly","rather","quite","presumably","undoubtedly","nonetheless","merely","particularly","peculiar","considerably","swift","seldom"}
    for _,w in ipairs(formalWords)do formal=formal+wordCount(low,w) end
    formal=formal+countPlain(low,"i cannot")+countPlain(low,"i do not")+countPlain(low,"i would not")
    local arist=0
    local aristPhrases={"one cannot","one might","i dare say","i must confess","i should think","it would appear","i find myself","one would","if you please","quite so"}
    for _,w in ipairs(aristPhrases)do arist=arist+countPlain(low,w) end
    local contractions=0
    for _ in low:gmatch("[%a]+'[%a]+")do contractions=contractions+1 end
    local you=wordCount(low,"you")+wordCount(low,"ya")
    local your=wordCount(low,"your")+wordCount(low,"yer")
    local them=wordCount(low,"them")+countPlain(low,"'em")
    local profanity=0
    for _,w in ipairs({"fuck","fucking","fucked","shit","damn","damned","bloody","bastard","piss","pissed","ass"})do profanity=profanity+wordCount(low,w) end
    return {at=time and time() or 0,words=words,slang=slang,dropG=dropG,ingEligible=ingEligible,formal=formal,arist=arist,contractions=contractions,ya=wordCount(low,"ya"),you=you,yer=wordCount(low,"yer"),your=your,em=countPlain(low,"'em"),them=them,aint=wordCount(low,"ain't"),gonna=wordCount(low,"gonna"),wanna=wordCount(low,"wanna"),gotta=wordCount(low,"gotta"),kinda=wordCount(low,"kinda"),sorta=wordCount(low,"sorta"),profanity=profanity}
end
local function pruneVoiceSamples(vl)
    local cutoff=(time and time() or 0)-VOICE_LEARN_WINDOW;local out={}
    for _,r in ipairs(vl.samples or{})do if type(r)=="table" and (tonumber(r.at) or 0)>=cutoff then out[#out+1]=r end end
    while #out>VOICE_LEARN_MAX do table.remove(out,1)end
    vl.samples=out
end
local function aggregateVoice(vl,afterSerial)
    pruneVoiceSamples(vl)
    local a={lines=0,mass=0,words=0,slang=0,dropG=0,ingEligible=0,formal=0,arist=0,contractions=0,ya=0,you=0,yer=0,your=0,em=0,them=0,aint=0,gonna=0,wanna=0,gotta=0,kinda=0,sorta=0,profanity=0,profanityLines=0,rawProfanityLines=0}
    local samples={};local floor=math.max(0,math.floor(tonumber(afterSerial)or 0))
    for _,r in ipairs(vl.samples or{})do if type(r)=="table"and math.floor(tonumber(r.serial)or 0)>floor then samples[#samples+1]=r end end
    local total=#samples;local t=time and time()or 0
    for i,r in ipairs(samples)do
        local age=math.max(0,t-(tonumber(r.at)or t))
        local recency=.5^(age/VOICE_RECENCY_HALF_LIFE)
        local order=.5^((total-i)/VOICE_ORDER_HALF_LIFE)
        local weight=math.max(.025,recency*order)
        a.lines=a.lines+1;a.mass=a.mass+weight
        for k in pairs(a)do
            if k~="lines"and k~="mass"and k~="profanityLines"and k~="rawProfanityLines"then a[k]=a[k]+((tonumber(r[k])or 0)*weight)end
        end
        if(tonumber(r.profanity)or 0)>0 then a.profanityLines=a.profanityLines+weight;a.rawProfanityLines=a.rawProfanityLines+1 end
    end
    return a
end
function RPAL.LearnPersonaVoiceFromICText(text,eventName,isOwnCharacter)
    local d=ensureLiving();if not d or not d.voiceLearning.enabled then return false end
    -- The caller must positively identify the player character. Nearby players'
    -- accents, slang and profanity are semantic input only and never train voice.
    if isOwnCharacter~=true and isOwnCharacter~="self"then return false,"self_only"end
    eventName=tostring(eventName or"")
    if eventName~="CHAT_MSG_SAY" and eventName~="CHAT_MSG_YELL" then return false end
    if RPAL.IsTRPInCharacter then local ok,res=pcall(RPAL.IsTRPInCharacter);if not ok or not res then return false end end
    if RPAL.GetPersonaInputClassification then local ok,r=pcall(RPAL.GetPersonaInputClassification,text);if ok and type(r)=="table" and r.ignored then return false end end
    local raw=trim(text);if RPAL.StripPersonaLanguageMarkers then raw=RPAL.StripPersonaLanguageMarkers(raw)end
    if raw=="" or raw:match("^%(%(") then return false end
    local sample=voiceSample(raw);if not sample then return false end
    local vl=d.voiceLearning;vl.nextSampleSerial=vl.nextSampleSerial+1;sample.serial=vl.nextSampleSerial
    vl.samples[#vl.samples+1]=sample;vl.lastLearnedAt=sample.at;pruneVoiceSamples(vl)
    return true
end
function RPAL.GetPersonaLearnedVoiceProfile()
    local d=ensureLiving();if not d then return {voice="casual",confidence=0,lines=0} end
    local a=aggregateVoice(d.voiceLearning,d.voiceLearning.voiceEvidenceAfterSerial)
    local mass=math.max(1,a.mass);local avg=a.words/mass
    local street=math.min(1,(a.slang/mass)*.24+(a.dropG/math.max(1,a.ingEligible))*.58+(a.aint/mass)*.18)
    local casual=math.min(.82,.18+math.min(.22,(a.contractions/mass)*.035)+((avg>=5 and avg<=15)and .16 or 0))
    local refined=math.min(1,(a.formal/mass)*.25+math.max(0,avg-10)*.018)
    local arist=math.min(1,(a.arist/mass)*.48+(a.formal/mass)*.12+math.max(0,avg-16)*.022)
    local scores={street=street,casual=casual,refined=refined,aristocratic=arist}
    -- The calibrated/manual choice supplies inertia while fresh self-authored IC
    -- speech earns increasing authority. It can be displaced, but never by a
    -- couple of unusual lines.
    local seedBonus=clamp(.25-(a.mass*.0032),.07,.25)
    scores[d.voice]=(scores[d.voice] or 0)+seedBonus
    local best,second="casual",-1
    for k,v in pairs(scores)do if v>(scores[best] or -1)then best=k end end
    for k,v in pairs(scores)do if k~=best and v>second then second=v end end
    local confidence=clamp((scores[best] or 0)-math.max(0,second)+math.min(.30,a.mass/58),0,1)
    if a.lines<12 or a.mass<8 or confidence<.30 then best=d.voice end
    return {voice=best,confidence=confidence,lines=a.lines,effectiveLines=a.mass,avgWords=avg,stats=a,scores=scores,seed=d.voice,seedBonus=seedBonus,source="self_ic_speech_only"}
end
function RPAL.GetPersonaEffectiveVoice()
    local d=ensureLiving();if not d then return "casual",{voice="casual",confidence=0,lines=0} end
    local learned=RPAL.GetPersonaLearnedVoiceProfile()
    if not d.voiceLearning.enabled then learned.effective=d.voice;return d.voice,learned end
    local current=VOICES[d.voiceLearning.lastEffective]and d.voiceLearning.lastEffective or d.voice
    local candidate=learned.voice or d.voice
    if candidate~=current then
        local scores=learned.scores or{};local margin=(tonumber(scores[candidate])or 0)-(tonumber(scores[current])or 0)
        -- Hysteresis keeps borderline samples from flipping the voice back and
        -- forth, while a sustained new register can still replace the seed.
        if learned.lines<12 or(tonumber(learned.effectiveLines)or 0)<8 or learned.confidence<.32 or margin<.10 then candidate=current end
    end
    if not VOICES[candidate]then candidate=d.voice end
    if candidate~=d.voiceLearning.lastEffective then d.voiceLearning.lastEffective=candidate;d.voiceLearning.lastEffectiveAt=time and time()or 0 end
    learned.effective=candidate
    return candidate,learned
end
function RPAL.GetPersonaLearnedProfanityProfile()
    local d=ensureLiving();if not d then return {level="none",baseline="none",confidence=0,lines=0,uses=0} end
    local a=aggregateVoice(d.voiceLearning,d.voiceLearning.profanityEvidenceAfterSerial)
    local lines=tonumber(a.lines)or 0;local mass=tonumber(a.mass)or 0
    local uses=tonumber(a.profanity)or 0
    local baseline=d.profanity or"none"
    local perLine=uses/math.max(1,mass);local perHundredWords=(uses/math.max(1,tonumber(a.words)or 0))*100
    local observed
    if uses<.20 or perHundredWords<.10 then observed="none"
    elseif perHundredWords<.45 then observed="rare"
    elseif perHundredWords<1.25 then observed="casual"
    elseif perHundredWords<2.75 then observed="frequent"
    else observed="heavy" end
    local evidence=clamp((mass-8)/48,0,1)
    local influence=.20+(.72*evidence)
    if uses<.20 then influence=influence*clamp(mass/34,0,1) end
    local baseIndex=PROFANITY_INDEX[baseline]or 1
    local observedIndex=PROFANITY_INDEX[observed]or 1
    local target=(baseIndex*(1-influence))+(observedIndex*influence)
    local idx=math.floor(target+.5);idx=math.max(1,math.min(#PROFANITY_ORDER,idx))
    local level=PROFANITY_ORDER[idx]
    local confidence=clamp(math.min(1,mass/42)*(uses>=.20 and(.44+math.min(.56,uses/8))or .72),0,1)
    if observedIndex>baseIndex and(tonumber(a.rawProfanityLines)or 0)<4 then level=baseline end
    if lines<14 or mass<9 or confidence<.28 then level=baseline end
    return {level=level,baseline=baseline,observed=observed,confidence=confidence,lines=lines,effectiveLines=mass,uses=uses,perLine=perLine,perHundredWords=perHundredWords,stats=a,source="self_ic_speech_only"}
end
function RPAL.GetPersonaEffectiveProfanity()
    local d=ensureLiving();if not d then return "none",{level="none",baseline="none",confidence=0,lines=0,uses=0} end
    local learned=RPAL.GetPersonaLearnedProfanityProfile()
    local effective=d.voiceLearning.enabled and learned.level or d.profanity
    if not PROFANITY[effective]then effective=d.profanity end
    if effective~=d.voiceLearning.lastEffectiveProfanity then d.voiceLearning.lastEffectiveProfanity=effective;d.voiceLearning.lastEffectiveProfanityAt=time and time()or 0 end
    learned.effective=effective
    return effective,learned
end
function RPAL.SetPersonaProfanityLevel(value)
    local d=ensureLiving();value=tostring(value or""):lower();if not d or not PROFANITY[value]then return false end
    if livingCalibrationLocked()then return false,"calibrated_locked"end
    d.profanity=value;d.voiceLearning.lastEffectiveProfanity=value;d.voiceLearning.lastEffectiveProfanityAt=time and time()or 0
    d.voiceLearning.profanityEvidenceAfterSerial=d.voiceLearning.nextSampleSerial
    if RPAL.RefreshPersonalityUI then RPAL.RefreshPersonalityUI()end;return true
end
function RPAL.SetPersonaIdleActivity(key,enabled)
    local d=ensureLiving();key=tostring(key or"");if not d or not IDLES[key]then return false end
    d.idle.selected[key]=enabled and true or nil
    d.state.nextIdleAt=0
    if RPAL.RefreshPersonalityUI then RPAL.RefreshPersonalityUI()end
    return true
end
function RPAL.SetPersonaIdleCurrentlyEnabled(enabled)
    local d=ensureLiving();if not d then return false end;d.idle.writeCurrently=enabled and true or false;return true
end
function RPAL.ApplyPersonaLivingCalibration(voice,profanity,idles,writeCurrently)
    local d=ensureLiving();if not d then return false end
    if livingCalibrationLocked()then return false,"calibrated_locked"end
    if VOICES[tostring(voice or""):lower()]then d.voice=tostring(voice):lower()end
    if PROFANITY[tostring(profanity or""):lower()]then d.profanity=tostring(profanity):lower()end
    d.voiceLearning.enabled=true
    d.voiceLearning.lastEffective=d.voice;d.voiceLearning.lastEffectiveAt=time and time()or 0
    d.voiceLearning.lastEffectiveProfanity=d.profanity;d.voiceLearning.lastEffectiveProfanityAt=time and time()or 0
    d.voiceLearning.voiceEvidenceAfterSerial=d.voiceLearning.nextSampleSerial
    d.voiceLearning.profanityEvidenceAfterSerial=d.voiceLearning.nextSampleSerial
    if type(idles)=="table"then d.idle.selected=copySet(idles)end
    if writeCurrently~=nil then d.idle.writeCurrently=writeCurrently and true or false end
    d.state.nextIdleAt=0
    return true
end
function RPAL.FinalizePersonaLivingCalibration()
    local d=ensureLiving();if not d then return false end
    -- Adaptive Voice is part of the calibrated model, not a post-calibration
    -- steering control. The player chooses only the two starting baselines.
    d.voiceLearning.enabled=true
    return true
end

-- Schema 13 makes the calibration-only Voice/Profanity boundary persistent
-- even when the Living Habits panel is never opened during the first login
-- after updating. Existing baselines and self-authored evidence are preserved.
if RPAL.RegisterDataMigration then
    RPAL.RegisterDataMigration(13,function(_,characterDB,scope)
        if type(scope)=="table"and not scope.needsCharacter then return end
        local seen={}
        local function visit(value,depth)
            if type(value)~="table"or seen[value]or depth>10 then return end
            seen[value]=true
            local personality=type(value.personality)=="table"and value.personality or nil
            local living=personality and personality.living
            if type(living)=="table"then
                living.voiceLearning=type(living.voiceLearning)=="table"and living.voiceLearning or{}
                local learning=type(personality.learning)=="table"and personality.learning or{}
                if learning.calibrated==true then living.voiceLearning.enabled=true end
                living.voiceLearning.calibrationLockV9MigratedAt=tonumber(living.voiceLearning.calibrationLockV9MigratedAt)or(time and time()or 0)
                living.version=math.max(9,math.floor(tonumber(living.version)or 0))
            end
            for _,child in pairs(value)do if type(child)=="table"then visit(child,depth+1)end end
        end
        visit(characterDB,0)
    end)
end

local function alignment()
    local a=(RPAL.GetEffectivePersonalityAlignment and RPAL.GetEffectivePersonalityAlignment())or((RPAL.GetPersonalityData and RPAL.GetPersonalityData()or{}).alignment)or{good=.34,neutral=.46,evil=.20}
    return a
end
local function moralKind()
    local a=alignment();local g,n,e=tonumber(a.good)or 0,tonumber(a.neutral)or 0,tonumber(a.evil)or 0
    if g>=.46 and g>e+.08 then return "good",a end
    if e>=.46 and e>g+.08 then return "dark",a end
    return "neutral",a
end

local MORAL_GENERAL={
    good={
        "I still feel bad when somebody gets hurt for no real reason.",
        "I keep thinking about whether anybody else got caught up in that.",
        "Seeing somebody treated badly tends to stay with me for a while.",
        "I do not like the feeling of making somebody else's day worse just because mine is difficult.",
        "I keep remembering the people who were decent to me when they did not have to be.",
        "Part of me still worries about the person who looked uncomfortable earlier.",
        "I feel better when I know nobody got pushed aside for my sake.",
        "I keep wondering whether somebody nearby could use a little company.",
        "It bothers me more than I expect when somebody gets left out.",
        "I am still thinking about whether that landed harder on them than they showed.",
        "I do not enjoy carrying anger once the moment itself has passed.",
        "I keep noticing when somebody looks like they are trying not to ask for help.",
    },
    neutral={
        "I keep thinking about what that actually changed.",
        "I am more interested in how this turns out than in making it sound clean.",
        "There are a few different ways this could go, and none of them feel especially simple.",
        "I keep weighing how much effort this is actually worth.",
        "Part of me is still sorting out what matters here and what is just noise.",
        "I do not feel strongly enough about that to turn it into a bigger thing.",
        "I keep wondering whether this will matter to me tomorrow as much as it does right now.",
        "There is a practical part of this I keep coming back to.",
        "I am still trying to work out what I actually get from caring about this.",
        "I keep noticing that the outcome matters more to me than the argument around it.",
        "This feels more complicated than simply liking or disliking what happened.",
        "I am not sure I care enough to take a side in that yet.",
    },
    dark={
        "I am still annoyed about how easily they made that my problem.",
        "I keep remembering who made things harder than they needed to be.",
        "Part of me is still enjoying the fact that they do not know exactly what I think.",
        "I have less patience for being generous when it keeps costing me.",
        "I keep thinking about how useful it is when people underestimate what I notice.",
        "I am not as bothered by their discomfort as I probably would have been once.",
        "I keep remembering the moment I realized I did not feel sorry for them.",
        "There are people I would rather leave to deal with their own mess.",
        "I am still irritated enough that the idea of helping them does not appeal to me.",
        "I keep noticing how much easier some choices feel when I stop worrying about being liked.",
        "I do not feel much sympathy for somebody who walked into that knowingly.",
        "Part of me is still keeping track of who made themselves difficult to trust.",
    },
}

-- TRP relation metadata is a declared prior, not observed RP evidence.
-- Custom/renamed relation labels can retain a built-in-looking ID underneath,
-- so ID alone is never enough to infer warmth/tension/romance.  Only a known
-- canonical ID + matching canonical display label earns a small semantic prior.
-- Any mismatch/custom/unknown relation is familiarity-only.
local RELATION_TONES={
    love={score=.24,label="love"},family={score=.18,label="family"},friend={score=.14,label="friendly"},
    business={score=.05,label="business"},neutral={score=0,label="neutral"},
    unfriendly={score=-.12,label="unfriendly"},rival={score=-.14,label="rival"},hate={score=-.22,label="hated"},
    familiar={score=0,label="familiar"},
}
local BUILTIN_RELATION_LABELS={
    LOVE={kind="love",labels={love=true,lover=true,["love interest"]=true,romance=true}},
    FAMILY={kind="family",labels={family=true,["family or close friends"]=true,["family or close friend"]=true}},
    FRIEND={kind="friend",labels={friend=true,friendly=true,["close friend"]=true}},
    BUSINESS={kind="business",labels={business=true}},
    NEUTRAL={kind="neutral",labels={neutral=true}},
    UNFRIENDLY={kind="unfriendly",labels={unfriendly=true}},
    RIVAL={kind="rival",labels={rival=true}},
    HATE={kind="hate",labels={hate=true,hated=true}},
}
local function relationLabelKey(value)
    value=tostring(value or""):gsub("|c%x%x%x%x%x%x%x%x",""):gsub("|r","")
    value=value:gsub("|H.-|h(.-)|h","%1"):gsub("|T.-|t",""):gsub("|A.-|a",""):gsub("|","")
    return trim(value:lower():gsub("_"," "):gsub("[%p]"," "):gsub("%s+"," "))
end
local function relationTone(bond)
    local id=trim(tostring(bond and bond.trpRelationID or"")):upper()
    local label=relationLabelKey(bond and(bond.trpRelationName or bond.relationship)or"")
    local def=BUILTIN_RELATION_LABELS[id]
    if def and def.labels[label]then
        local tone=RELATION_TONES[def.kind]or RELATION_TONES.neutral
        return def.kind,tone,{semanticVerified=true,familiarityOnly=false,relationID=id,relationLabel=label}
    end
    -- NONE, CUSTOM*, renamed built-ins, localized/unrecognized labels, and any
    -- inconsistent ID/label pair are deliberately non-semantic.  They only say
    -- the player has categorized/seen this person before.
    return "familiar",RELATION_TONES.familiar,{semanticVerified=false,familiarityOnly=true,relationID=id,relationLabel=label}
end

local function stripTRPMarkup(value)
    value=tostring(value or""):gsub("|c%x%x%x%x%x%x%x%x",""):gsub("|r","")
    value=value:gsub("|H.-|h(.-)|h","%1"):gsub("|T.-|t",""):gsub("|A.-|a",""):gsub("|","")
    return trim(value:gsub("[%z\1-\31]"," "):gsub("%s+"," "))
end
local function personaIdentityKey(value)
    return stripTRPMarkup(value):lower():gsub("’","'"):gsub("[%s%-']+"," "):gsub("^%s+",""):gsub("%s+$","")
end

local function profileFromID(profileID)
    if not profileID or tostring(profileID)=="" then return nil end
    local api=_G.TRP3_API;local register=type(api)=="table"and api.register or nil
    if type(register)=="table"and type(register.getProfileOrNil)=="function"then
        local ok,v=pcall(register.getProfileOrNil,tostring(profileID));if ok and type(v)=="table"then return v end
    end
    if type(_G.TRP3_Register)=="table"and type(_G.TRP3_Register.profiles)=="table"then return _G.TRP3_Register.profiles[tostring(profileID)]end
    return nil
end

local function profileNames(profile)
    local ch=type(profile)=="table"and profile.characteristics or nil
    local first=ch and stripTRPMarkup(ch.FN)or""
    local last=ch and stripTRPMarkup(ch.LN)or""
    local full=stripTRPMarkup(first..((first~=""and last~="")and" "or"")..last)
    return first,full
end

local function resolveBondPersonaName(bond)
    if type(bond)~="table"then return nil end
    -- Re-resolve the profile whenever possible so old SavedVariables created
    -- before trpHasRoleplayName existed cannot leak a WoW unit name into Persona.
    local first,full=profileNames(profileFromID(bond.trpProfileID))
    if first~=""and full~=""then return first,full end
    if bond.trpHasRoleplayName==true then
        first=stripTRPMarkup(bond.trpFirstName)
        full=stripTRPMarkup(bond.trpName)
        if first~=""and full~=""then return first,full end
    end
    return nil
end

local function normalizeUnitKey(v)
    return tostring(v or""):lower():gsub("%s",""):gsub("'","")
end
local function shortUnitKey(v)return normalizeUnitKey(v):match("^[^-]+")or normalizeUnitKey(v)end

-- Persona identity resolution used to walk the entire TRP character register
-- once for every observed person. On populated RP realms that turns a single
-- mouseover into dozens of full registry scans. Build one short-lived index and
-- reuse it instead; profile/register changes still become visible within a few
-- seconds without making cursor movement a database operation.
local TRP_IDENTITY_INDEX_TTL=8
local trpIdentityIndex
local trpIdentityIndexAt=-100
local trpIdentityIndexSource
local function monotonicNow()return GetTime and GetTime()or time()end
local function getTRPIdentityIndex()
    local chars=type(_G.TRP3_Register)=="table"and _G.TRP3_Register.character or nil
    if type(chars)~="table"then return nil,nil end
    local t=monotonicNow()
    if trpIdentityIndex and trpIdentityIndexSource==chars and t-trpIdentityIndexAt<TRP_IDENTITY_INDEX_TTL then
        return trpIdentityIndex.exact,trpIdentityIndex.short
    end
    local exact,short,ambiguous={},{},{}
    for unitID,row in pairs(chars)do
        local profileID=type(row)=="table"and row.profileID or nil
        if profileID then
            profileID=tostring(profileID)
            local key=normalizeUnitKey(unitID)
            if key~=""then
                exact[key]=profileID
                local shortKey=shortUnitKey(key)
                if shortKey~=""and not ambiguous[shortKey]then
                    local previous=short[shortKey]
                    if previous and previous~=profileID then short[shortKey]=nil;ambiguous[shortKey]=true
                    else short[shortKey]=profileID end
                end
            end
        end
    end
    trpIdentityIndex={exact=exact,short=short}
    trpIdentityIndexAt=t
    trpIdentityIndexSource=chars
    return exact,short
end

local function resolveObservedPersonaName(unitKey)
    local wanted=normalizeUnitKey(unitKey);local wantedShort=shortUnitKey(unitKey)
    local exact,short=getTRPIdentityIndex();if not exact then return nil end
    local profileID=exact[wanted]or short[wantedShort]
    local first,full=profileNames(profileFromID(profileID))
    if first~=""and full~=""then return first,full,profileID end
    return nil
end
RPAL.ResolvePersonaObservedName=resolveObservedPersonaName

local RECENT_CONTEXT_CACHE_TTL=10
local recentTRPInteractionCache
local recentTRPInteractionCacheAt=-100
local recentObservedNegativeCache
local recentObservedNegativeCacheAt=-100
local function recentTRPInteractionMap()
    local runtimeAt=monotonicNow()
    if recentTRPInteractionCache and runtimeAt-recentTRPInteractionCacheAt<RECENT_CONTEXT_CACHE_TTL then return recentTRPInteractionCache end
    local out={};local cutoff=time()-(30*24*60*60)
    local moments=RPAL.GetMoments and RPAL.GetMoments()or{}
    for _,moment in ipairs(type(moments)=="table"and moments or{})do
        local at=tonumber(moment.at)or tonumber(moment.endedAt)or 0
        if at>=cutoff then
            local touched={}
            for _,person in ipairs(type(moment.peopleDetails)=="table"and moment.peopleDetails or{})do
                local full=stripTRPMarkup(person.trpName)
                if full~=""and(person.activeRP==true or person.met==true or(tonumber(person.interactions)or 0)>0)then
                    local key=personaIdentityKey(full);local row=out[key]or{sessions=0,interactions=0,lastAt=0,firstAt=0}
                    row.interactions=row.interactions+math.max(0,tonumber(person.interactions)or 0)
                    row.lastAt=math.max(row.lastAt,at);row.firstAt=row.firstAt==0 and at or math.min(row.firstAt,at);out[key]=row
                    if not touched[key]then row.sessions=row.sessions+1;touched[key]=true end
                end
            end
        end
    end
    recentTRPInteractionCache=out;recentTRPInteractionCacheAt=runtimeAt
    return out
end

local NEGATIVE_COGNITIVE_EVENT={RELATIONSHIP_BREAK=true,REJECTION=true,INSULT=true,THREAT=true,AGGRESSION=true,TRUST_BROKEN=true,TRUST_ACCUSATION=true,HOSTILITY=true,MOCKERY=true,SOCIAL_DISAPPROVAL=true,HUMOR_REJECTED=true}
local function recentObservedNegativeMap()
    local runtimeAt=monotonicNow()
    if recentObservedNegativeCache and runtimeAt-recentObservedNegativeCacheAt<RECENT_CONTEXT_CACHE_TTL then return recentObservedNegativeCache end
    local out={};local cutoff=time()-(7*24*60*60)
    for _,ev in ipairs((RPAL.GetPersonaCognitiveEvents and RPAL.GetPersonaCognitiveEvents(96))or{})do
        local at=tonumber(ev.at)or 0;local name=ev.person and stripTRPMarkup(ev.person.name)or""
        if at>=cutoff and name~=""and NEGATIVE_COGNITIVE_EVENT[tostring(ev.type or"")]then
            local key=personaIdentityKey(name);out[key]=math.max(tonumber(out[key])or 0,at)
        end
    end
    recentObservedNegativeCache=out;recentObservedNegativeCacheAt=runtimeAt
    return out
end
local function freshnessWeight(at)
    local age=math.max(0,time()-(tonumber(at)or 0))/86400
    if not at or tonumber(at)==0 then return 0,999 end
    if age<=1 then return 1,age elseif age<=3 then return .86,age elseif age<=7 then return .62,age elseif age<=14 then return .34,age elseif age<=30 then return .12,age end
    return 0,age
end
local function ambientPolicy(kind,rr,first,full,negative)
    rr=type(rr)=="table"and rr or{};local fresh,age=freshnessWeight(rr.lastAt)
    local interactions=tonumber(rr.interactions)or 0;local sessions=tonumber(rr.sessions)or 0
    local nk=personaIdentityKey(first);local fk=personaIdentityKey(full);local negativeAt=math.max(tonumber(negative[nk])or 0,tonumber(negative[fk])or 0)
    local isNegative=kind=="unfriendly"or kind=="rival"or kind=="hate"
    local eligible=false
    if isNegative then
        eligible=negativeAt>0 and age<=7 and(interactions>0 or sessions>0)
    elseif kind=="love"or kind=="family"or kind=="friend"then
        eligible=age<=14 and(interactions>0 or sessions>0)
    elseif kind=="friendly"or kind=="business"then
        eligible=age<=10 and(interactions>=2 or sessions>=2)
    end
    return eligible,fresh,age,negativeAt
end

local RELATIONSHIP_VIEW_CACHE_TTL=8
local relationshipCandidatesCache
local relationshipCandidatesCacheAt=-100
local function limitedRows(rows,limit,maximum,fallback)
    limit=math.max(1,math.min(maximum,math.floor(tonumber(limit)or fallback)))
    local out={}
    for i=1,math.min(limit,#(rows or{}))do out[i]=rows[i]end
    return out
end

function RPAL.GetPersonaTRPRelationshipCandidates(limit)
    local runtimeAt=monotonicNow()
    if relationshipCandidatesCache and runtimeAt-relationshipCandidatesCacheAt<RELATIONSHIP_VIEW_CACHE_TTL then
        return limitedRows(relationshipCandidatesCache,limit,50,20)
    end
    if RPAL.SyncTRPRelationsToBonds then pcall(RPAL.SyncTRPRelationsToBonds,false)end
    local d=RPAL.GetRoleplayData and RPAL.GetRoleplayData();local out,byName={},{};if not d then return out end
    local recent=recentTRPInteractionMap();local negative=recentObservedNegativeMap()
    for key,bond in pairs(type(d.bonds)=="table"and d.bonds or{})do
        if type(bond)=="table"and(bond.trpImported or bond.trpRelationID or bond.relationshipSource=="TRP")then
            local kind,tone,relationMeta=relationTone(bond);local first,full=resolveBondPersonaName(bond)
            -- Neutral profiles remain available for live speaker resolution, but
            -- they never become ambient named thoughts merely for existing.
            if first and first~=""then
                local rr=recent[personaIdentityKey(full)]or{}
                local ambient,fresh,age,negativeAt=ambientPolicy(kind,rr,first,full,negative)
                local row={key=key,name=first,fullName=full,kind=kind,score=tone.score,label=tone.label,bond=bond,lastSeen=tonumber(bond.lastSeen)or tonumber(bond.trpLastSeen)or 0,recentInteractions=tonumber(rr.interactions)or 0,recentSessions=tonumber(rr.sessions)or 0,recentAt=tonumber(rr.lastAt)or 0,interactionAgeDays=age,freshnessWeight=fresh,ambientEligible=ambient,negativeEvidenceRecent=negativeAt>0,negativeEvidenceAt=negativeAt,semanticVerified=relationMeta and relationMeta.semanticVerified or false,familiarityOnly=relationMeta and relationMeta.familiarityOnly or true,declaredRelationID=relationMeta and relationMeta.relationID or nil,declaredRelationLabel=relationMeta and relationMeta.relationLabel or nil,familiarityPrior=.12}
                local id=personaIdentityKey(full);local old=byName[id]
                if old then
                    old.recentInteractions=math.max(old.recentInteractions,row.recentInteractions);old.recentSessions=math.max(old.recentSessions,row.recentSessions);old.recentAt=math.max(old.recentAt,row.recentAt);old.lastSeen=math.max(old.lastSeen,row.lastSeen);old.freshnessWeight=math.max(old.freshnessWeight,row.freshnessWeight);old.ambientEligible=old.ambientEligible or row.ambientEligible;old.negativeEvidenceRecent=old.negativeEvidenceRecent or row.negativeEvidenceRecent;old.negativeEvidenceAt=math.max(old.negativeEvidenceAt,row.negativeEvidenceAt)
                    if row.semanticVerified and not old.semanticVerified then old.kind=row.kind;old.score=row.score;old.label=row.label;old.bond=row.bond;old.key=row.key end
                    old.semanticVerified=(old.semanticVerified or row.semanticVerified)and true or false;old.familiarityOnly=not old.semanticVerified
                    old.familiarityPrior=math.max(tonumber(old.familiarityPrior)or 0,tonumber(row.familiarityPrior)or 0)
                else byName[id]=row;out[#out+1]=row end
            end
        end
    end
    table.sort(out,function(a,b)
        if a.ambientEligible~=b.ambientEligible then return a.ambientEligible end
        local ar=((a.recentInteractions or 0)>=2 or(a.recentSessions or 0)>=2)and 1 or 0
        local br=((b.recentInteractions or 0)>=2 or(b.recentSessions or 0)>=2)and 1 or 0
        if ar~=br then return ar>br end
        local ap=(a.recentSessions or 0)*5+(a.recentInteractions or 0)*2+math.abs(a.score)*4+(a.freshnessWeight or 0)*6
        local bp=(b.recentSessions or 0)*5+(b.recentInteractions or 0)*2+math.abs(b.score)*4+(b.freshnessWeight or 0)*6
        if ap==bp then return math.max(a.recentAt or 0,a.lastSeen or 0)>math.max(b.recentAt or 0,b.lastSeen or 0)end
        return ap>bp
    end)
    while#out>50 do table.remove(out)end
    relationshipCandidatesCache=out;relationshipCandidatesCacheAt=runtimeAt
    return limitedRows(out,limit,50,20)
end

-- Merge observed relationship evidence with TRP identities, but never expose a
-- bare WoW character name as a Persona identity. The display field is always FN.
local baseGetPersonaPeople=RPAL.GetPersonaPeople
local personaPeopleCache
local personaPeopleCacheAt=-100
if baseGetPersonaPeople then
    RPAL.GetPersonaPeople=function(limit)
        local runtimeAt=monotonicNow()
        if personaPeopleCache and runtimeAt-personaPeopleCacheAt<RELATIONSHIP_VIEW_CACHE_TTL then
            return limitedRows(personaPeopleCache,limit,20,8)
        end
        local observed=baseGetPersonaPeople(30)or{};local merged={};local order={}
        local recent=recentTRPInteractionMap();local negative=recentObservedNegativeMap()
        local function norm(v)return personaIdentityKey(v)end
        for _,row in ipairs(observed)do
            local first,full=resolveObservedPersonaName(row.key)
            if first and full then
                local k=norm(full);local rr=recent[k]or{}
                local fresh,age=freshnessWeight(rr.lastAt);local negativeAt=math.max(tonumber(negative[personaIdentityKey(first)])or 0,tonumber(negative[personaIdentityKey(full)])or 0)
                local score=tonumber(row.score)or 0;local actual=(tonumber(rr.interactions)or 0)>0 or(tonumber(rr.sessions)or 0)>0
                local ambient=actual and age<=14 and math.abs(score)>=.26 and(score>=0 or negativeAt>0)
                row.display=first;row.trpFullName=full;row.recentInteractions=tonumber(rr.interactions)or 0;row.recentSessions=tonumber(rr.sessions)or 0;row.recentAt=tonumber(rr.lastAt)or 0;row.interactionAgeDays=age;row.freshnessWeight=fresh;row.ambientEligible=ambient;row.negativeEvidenceRecent=negativeAt>0;row.negativeEvidenceAt=negativeAt
                merged[k]=row;order[#order+1]=k
            end
        end
        for _,seed in ipairs(RPAL.GetPersonaTRPRelationshipCandidates(40)or{})do
            local k=norm(seed.fullName);local existing=merged[k]
            if existing then
                local count=tonumber(existing.count)or 0
                local observedScore=tonumber(existing.score)or 0
                if seed.semanticVerified then
                    -- Declared TRP relation is only a light prior.  Real observed
                    -- RP quickly outweighs it; custom relations never alter score.
                    local priorWeight=math.max(.05,.16-math.min(.11,count*.02))
                    existing.score=clamp(observedScore*(1-priorWeight)+(tonumber(seed.score)or 0)*priorWeight,-1,1)
                else existing.score=observedScore end
                existing.confidence=math.max(tonumber(existing.confidence)or 0,seed.semanticVerified and .34 or .20)
                existing.trpRelation=seed.kind;existing.trpSeedScore=seed.semanticVerified and seed.score or 0;existing.trpSemanticVerified=seed.semanticVerified and true or false;existing.trpFamiliarityOnly=seed.familiarityOnly and true or false;existing.familiarityPrior=tonumber(seed.familiarityPrior)or .12;existing.source=seed.semanticVerified and"TRP prior + observed"or"TRP familiarity + observed";existing.display=seed.name;existing.trpFullName=seed.fullName
                existing.recentInteractions=math.max(tonumber(existing.recentInteractions)or 0,seed.recentInteractions or 0);existing.recentSessions=math.max(tonumber(existing.recentSessions)or 0,seed.recentSessions or 0);existing.recentAt=math.max(tonumber(existing.recentAt)or 0,seed.recentAt or 0)
                existing.interactionAgeDays=math.min(tonumber(existing.interactionAgeDays)or 999,tonumber(seed.interactionAgeDays)or 999);existing.freshnessWeight=math.max(tonumber(existing.freshnessWeight)or 0,tonumber(seed.freshnessWeight)or 0);existing.ambientEligible=(existing.ambientEligible or seed.ambientEligible)and true or false;existing.negativeEvidenceRecent=(existing.negativeEvidenceRecent or seed.negativeEvidenceRecent)and true or false;existing.negativeEvidenceAt=math.max(tonumber(existing.negativeEvidenceAt)or 0,tonumber(seed.negativeEvidenceAt)or 0)
                if(tonumber(existing.score)or 0)<0 and not existing.negativeEvidenceRecent then existing.ambientEligible=false end
            else
                local row={key=seed.key,display=seed.name,trpFullName=seed.fullName,score=seed.semanticVerified and seed.score or 0,confidence=seed.semanticVerified and .34 or .20,count=tonumber(seed.bond and seed.bond.interactions)or 0,lastAt=seed.lastSeen,state=seed.kind,trpRelation=seed.kind,trpSeedScore=seed.semanticVerified and seed.score or 0,trpSemanticVerified=seed.semanticVerified and true or false,trpFamiliarityOnly=seed.familiarityOnly and true or false,familiarityPrior=tonumber(seed.familiarityPrior)or .12,source=seed.semanticVerified and"TRP prior"or"TRP familiarity",recentInteractions=seed.recentInteractions or 0,recentSessions=seed.recentSessions or 0,recentAt=seed.recentAt or 0,interactionAgeDays=seed.interactionAgeDays,freshnessWeight=seed.freshnessWeight,ambientEligible=seed.ambientEligible,negativeEvidenceRecent=seed.negativeEvidenceRecent,negativeEvidenceAt=seed.negativeEvidenceAt}
                merged[k]=row;order[#order+1]=k
            end
        end
        local out={};local seen={}
        for _,k in ipairs(order)do if not seen[k]and merged[k]then seen[k]=true;out[#out+1]=merged[k]end end
        table.sort(out,function(a,b)
            if a.ambientEligible~=b.ambientEligible then return a.ambientEligible end
            local ar=((a.recentInteractions or 0)>=2 or(a.recentSessions or 0)>=2)and 2 or((a.recentInteractions or 0)>0 and 1 or 0)
            local br=((b.recentInteractions or 0)>=2 or(b.recentSessions or 0)>=2)and 2 or((b.recentInteractions or 0)>0 and 1 or 0)
            local av=ar*4+(a.recentSessions or 0)*.7+math.min(8,a.recentInteractions or 0)*.22+math.abs(tonumber(a.score)or 0)*(.45+.55*(tonumber(a.confidence)or 0))
            local bv=br*4+(b.recentSessions or 0)*.7+math.min(8,b.recentInteractions or 0)*.22+math.abs(tonumber(b.score)or 0)*(.45+.55*(tonumber(b.confidence)or 0))
            if av==bv then return math.max(tonumber(a.recentAt)or 0,tonumber(a.lastAt)or 0)>math.max(tonumber(b.recentAt)or 0,tonumber(b.lastAt)or 0)end;return av>bv
        end)
        while#out>20 do table.remove(out)end
        personaPeopleCache=out;personaPeopleCacheAt=runtimeAt
        return limitedRows(out,limit,20,8)
    end
end

local RELATION_BANK={
    warm={
        "Wonder what %s is up to.","Wouldn't mind running into %s again.","Been a while since I properly caught up with %s.","%s has crossed my mind a few times lately.","Talking to %s again sounds pretty good.","I miss having %s around more than I expected.","Funny how easy it is to think of %s when things go quiet.","I could go for %s's company right about now.","I wonder what %s would make of all this.","I haven't heard from %s in a bit.","Seeing %s again would be a nice surprise.","I kind of miss the way conversations with %s tend to go.","%s is one of the few people I don't mind bumping into without warning.","Wonder if %s is having a better day than I am.","Some people are easy to forget for a while. %s isn't one of them.","I could probably lose an hour talking to %s without noticing.","%s has become a strangely familiar part of things.","Would be good to hear %s's voice again.","I wonder where %s ended up today.","%s would probably make this less dull.","I haven't properly laughed with %s in a while.","Could use one of those easy conversations with %s.","%s keeps turning up in my head at odd moments.","I wonder if %s has been keeping busy.","Running into %s again wouldn't be bad at all.","There are worse ways to spend time than around %s.","I miss the familiarity of having %s nearby.","Wonder what sort of trouble %s has found lately.","%s is getting hard to think of as just another acquaintance.","It's nice having at least one person like %s in the world."
    },
    love={
        "I wonder what %s is doing right now.","I could really use %s around.","%s has been on my mind more than usual.","Everything feels a little easier when %s is around.","I keep noticing that I miss %s more than I expected.","Wouldn't mind seeing %s's face right now.","Funny how quickly my thoughts wander back to %s.","I wonder if %s is thinking of me too.","I could listen to %s ramble for a while.","%s makes quiet moments feel less empty.","I haven't had enough of %s lately.","I wonder what %s would say if they were here.","The thought of seeing %s again is doing more for my mood than it should.","I miss the little things about having %s around.","I keep thinking about %s whenever things get quiet.","Could use some time alone with %s.","I keep catching myself wondering where %s is.","%s has a habit of making everything else feel a little less urgent.","I want to see %s again soon.","It's been too quiet without %s around.","I wonder how %s is really doing.","A bit of time with %s sounds better than most things right now.","I can almost hear %s commenting on this.","%s has been turning up in my thoughts a lot lately."
    },
    family={
        "Wonder how %s is doing.","Quiet moments have been making me think about %s lately.","Been a while since I heard from %s.","I keep finding myself thinking about %s every so often.","I wonder what %s would say about this.","I miss %s more on quiet days.","Some habits always end up reminding me of %s.","I wonder whether %s is keeping out of trouble.","%s would probably have an opinion about this.","Funny what makes me think of %s.","I haven't checked in on %s in a while.","Part of home still sounds a little like %s.","I wonder what %s has been up to lately.","I have known %s long enough that thinking about them feels familiar.","I can almost picture %s reacting to this.","Been too long since %s and I properly talked.","I wonder if %s still remembers that the same way I do.","Some memories carry %s's voice whether I want them to or not.","I could stand to see %s again.","%s has a way of showing up in my thoughts when I least expect it."
    },
    business={
        "Wonder whether %s has made any progress.","Something about work keeps bringing %s to mind.","%s is one of those people I associate with getting things done.","I wonder what %s is working on now.","Wouldn't hurt to know what %s has moving at the moment.","%s has been useful to know.","I wonder whether %s has heard anything interesting lately.","Work tends to run smoother when %s is involved.","I keep thinking %s might know something about this.","%s is reliable enough that I notice when they're not around.","Could probably get a straight answer out of %s.","I wonder if %s has run into the same problem.","%s might actually have a useful angle on this.","Been a while since I crossed paths with %s on business.","I wonder what sort of opportunity %s is chasing now.","%s has become a familiar name whenever work comes up.","Wouldn't mind comparing notes with %s sometime.","I wonder if %s has anything worth hearing.","%s and I tend to understand the practical side of things.","There might be a reason %s keeps coming to mind whenever this sort of work appears."
    },
    tense={
        "Something about %s still gets under my skin.","I could do without seeing %s for a while.","Even thinking about %s is a little tiring.","I still don't know what to make of %s.","I keep thinking about %s even though I would rather not.","I haven't forgotten the way %s handled that.","I'd rather not deal with %s unless I have a reason.","The thought of %s still brings that irritation back.","%s is not somebody I miss having around.","I wonder if %s is still as difficult as I remember.","Something about %s makes me tense before anything even happens.","Thinking about %s still puts me in a worse mood.","I could happily go a while without crossing paths with %s.","I tend to feel more tense when %s is around.","I don't like how easily %s can sour my mood.","I wonder whether %s realizes how exhausting they can be.","Just remembering %s is enough to bring the old irritation back.","I still do not feel like %s is worth the trouble.","I have no particular urge to see %s again.","I expected the annoyance around %s to fade faster than it has.","I still feel a little guarded whenever %s comes to mind.","%s is easier to tolerate from a distance.","I wonder what new headache %s has found by now.","Nothing about running into %s sounds appealing right now."
    }
}

local function recentRawMap(p)
    local seen={};local h=type(p and p.history)=="table"and p.history or{};local first=math.max(1,#h-99)
    for i=first,#h do local r=h[i];if type(r)=="table"then if r.rawText then seen[tostring(r.rawText)]=true end;if r.text then seen[tostring(r.text)]=true end end end
    return seen
end
local function freshFormatted(pool,name,p)
    local recent=recentRawMap(p);local candidates={}
    for _,fmt in ipairs(pool or{})do local line=string.format(fmt,name);if not recent[line]then candidates[#candidates+1]=line end end
    if#candidates==0 then candidates=pool or{};if#candidates==0 then return nil end;local fmt=candidates[math.random(1,#candidates)];return string.format(fmt,name)end
    return candidates[math.random(1,#candidates)]
end

local function relationshipThought()
    local list=RPAL.GetPersonaTRPRelationshipCandidates and RPAL.GetPersonaTRPRelationshipCandidates(30)or{};if#list==0 then return nil end
    local weighted={}
    for _,r in ipairs(list)do
        if r.ambientEligible then
        local n=1+math.floor(math.abs(r.score)*3+.5)+math.floor((tonumber(r.freshnessWeight)or 0)*5+.5)
        local recentInteractions=tonumber(r.recentInteractions)or 0;local recentSessions=tonumber(r.recentSessions)or 0
        -- Regulars from the last 30 days dominate most rolls without becoming a hard lock.
        if recentInteractions>=2 or recentSessions>=2 then n=n+10+math.min(12,recentInteractions*2+recentSessions*2)
        elseif recentInteractions>0 then n=n+3 end
        if r.kind=="business"then n=n+2 end
        for _=1,n do weighted[#weighted+1]=r end
        end
    end
    if#weighted==0 then return nil end
    local r=weighted[math.random(1,#weighted)];local name=r.name;local mk=moralKind();local bank
    local p=RPAL.GetPersonalityData and RPAL.GetPersonalityData()or nil
    if RPAL.GeneratePersonaRelationshipThought then
        local raw,mind=RPAL.GeneratePersonaRelationshipThought(r,p,"trp")
        if raw then return raw,"social","trp-"..tostring(r.kind or"relationship"),mind end
    end
    if r.kind=="love"then bank=RELATION_BANK.love
    elseif r.kind=="family"then bank=RELATION_BANK.family
    elseif r.kind=="business"then bank=RELATION_BANK.business
    elseif r.kind=="friend"or r.kind=="friendly"then bank=RELATION_BANK.warm
    else bank=RELATION_BANK.tense end
    local line=freshFormatted(bank,name,p);if not line then return nil end
    -- Moral Core colors the interpretation, not the grammar. Add a sparse second
    -- clause only when it sounds like an actual private reaction.
    if mk=="dark"and(r.kind=="love"or r.kind=="friend"or r.kind=="friendly")and math.random()<.14 then line=line:gsub("%.$","")..". Trusting that feeling still makes me wary."
    elseif mk=="good"and(r.kind=="unfriendly"or r.kind=="rival"or r.kind=="hate")and math.random()<.12 then line=line:gsub("%.$","")..". I don't like how much space that irritation takes up."
    elseif mk=="dark"and(r.kind=="unfriendly"or r.kind=="rival"or r.kind=="hate")and math.random()<.13 then line=line:gsub("%.$","")..". I haven't forgotten why."
    end
    return line,"social","trp-"..r.kind
end

local function injuryCandidate()
    local db=RPAL.GetHealth2DB and RPAL.GetHealth2DB();if not db then return nil end
    local rank={minor=1,moderate=2,severe=3,critical=4};local choices={}
    for _,item in pairs(type(db.items)=="table"and db.items or{})do
        if type(item)=="table"and item.category=="injury"and item.stage~="healed"then
            local sev=tostring(item.severity or"moderate"):lower();local r=rank[sev]or 2
            for _=1,r do choices[#choices+1]=item end
        end
    end
    if#choices==0 then return nil end
    table.sort(choices,function(a,b)return(rank[tostring(a.severity or"moderate"):lower()]or 2)>(rank[tostring(b.severity or"moderate"):lower()]or 2)end)
    return choices[math.random(1,math.min(#choices,math.max(2,math.floor(#choices*.65))))]
end
local function injuryThought()
    local item=injuryCandidate();if not item then return nil end
    local sev=tostring(item.severity or"moderate"):lower();local chance=({minor=.05,moderate=.12,severe=.25,critical=.39})[sev]or.12
    if math.random()>chance then return nil end
    local label=trim(item.customName or item.type or"injury");local region=RPAL.HealthGetRegion and trim(RPAL.HealthGetRegion(item))or"";local where=region~=""and(" in my "..region:lower())or""
    if RPAL.GeneratePersonaInjuryThought then
        local raw,mind=RPAL.GeneratePersonaInjuryThought(item,RPAL.GetPersonalityData and RPAL.GetPersonalityData()or nil)
        if raw then return raw,"comfort","health-"..sev,mind end
    end
    local mk=moralKind();local pool
    if sev=="critical"then
        if mk=="dark"then pool={"This "..label..where.." is bad. I can feel how much control it is taking away.","That "..label..where.." is serious. I hate how visible weakness feels.","Pain is starting to crowd out everything else."}
        elseif mk=="good"then pool={"This "..label..where.." is serious. The idea of becoming somebody else's emergency sits badly with me.","I cannot pretend this "..label.." is manageable anymore.","This is bad. Even thinking clearly is getting harder."}
        else pool={"This "..label..where.." is serious.","I cannot keep pretending the "..label.." is background noise.","That pain is getting too difficult to ignore."}end
    elseif sev=="severe"then
        if mk=="dark"then pool={"That "..label..where.." is slowing me down, and I hate it.","Pain I can tolerate. Feeling predictable because of it is worse.","The "..label..where.." keeps advertising itself every time I move."}
        elseif mk=="good"then pool={"That "..label..where.." is getting hard to ignore.","This "..label.." is starting to affect more than just me.","Every time I push through it, the ache comes back meaner."}
        else pool={"That "..label..where.." is slowing me down.","The "..label..where.." is starting to demand too much attention.","That ache keeps cutting into whatever else I am thinking about."}end
    else
        if mk=="dark"then pool={"That "..label..where.." is irritating more than anything.","I hate how even a small "..label.." can make weakness feel obvious.","That "..label.." is still there, nagging at the edge of everything."}
        elseif mk=="good"then pool={"That "..label..where.." is manageable, but it keeps reminding me it exists.","The "..label..where.." is more annoying than painful now.","Every so often that "..label.." pulls my attention back to it."}
        else pool={"That "..label..where.." is annoying.","The "..label..where.." is still there.","That little ache keeps turning up at inconvenient moments."}end
    end
    return pool[math.random(1,#pool)],"comfort","health-"..sev
end

local function chance(p)return math.random()<p end
local function replaceOnce(line,plain,replacement)
    local at=line:find(plain,1,true);if not at then return line,false end
    return line:sub(1,at-1)..replacement..line:sub(at+#plain),true
end

local function pick(pool)
    return pool[math.random(1,#pool)]
end

local function phrase(line,pattern,pool)
    local caps={line:match(pattern)}
    if #caps==0 or caps[1]==nil then return nil end
    local fmt=pick(pool)
    local ok,out=pcall(string.format,fmt,unpack(caps))
    return ok and out or nil
end

local function contractCasual(line)
    return line
        :gsub("I do not","I don't"):gsub("I cannot","I can't"):gsub("I would not","I wouldn't")
        :gsub("I have not","I haven't"):gsub("It has been","It's been"):gsub("There is","There's")
        :gsub("That is","That's"):gsub("it is","it's"):gsub("do not","don't"):gsub("cannot","can't")
        :gsub("would not","wouldn't"):gsub("have not","haven't"):gsub("has not","hasn't")
end

local function expandContractions(line)
    return line
        :gsub("I don't","I do not"):gsub("I can't","I cannot"):gsub("I wouldn't","I would not")
        :gsub("I haven't","I have not"):gsub("I'm","I am"):gsub("I've","I have"):gsub("I'd","I would")
        :gsub("There's","There is"):gsub("That's","That is"):gsub("It's","It is")
        :gsub("isn't","is not"):gsub("aren't","are not"):gsub("hasn't","has not"):gsub("haven't","have not")
        :gsub("won't","will not"):gsub("can't","cannot"):gsub("wouldn't","would not"):gsub("don't","do not")
end

-- Street is a vocabulary/register pass, not a profanity switch.  These
-- rewrites stay active even with PROFANITY: NONE so Street remains audibly
-- different from Casual without forcing swear words into every thought.
local function applyStreetSlang(line)
    local function swap(plain,pool)
        if line:find(plain,1,true) then
            line=(line:gsub(plain,pick(pool),1))
            return true
        end
        return false
    end

    -- Common inner-speech constructions.
    swap("I don't know",{"I got no clue","Couldn't tell you","I ain't got a clue"})
    swap("I do not know",{"I got no clue","Couldn't tell you","I ain't got a clue"})
    swap("I cannot quite say",{"Couldn't tell you","Got no real clue","I ain't sure"})
    swap("I have no idea",{"I got no clue","No clue","Couldn't tell you"})
    swap("I am not sure",{"I ain't sure","Not sure","Can't really tell"})
    swap("I am unsure",{"I ain't sure","Not sure","Can't quite tell"})
    swap("I would not mind",{"Wouldn't mind","Wouldn't say no to","Could go for"})
    swap("I would rather welcome",{"Could really go for","Wouldn't mind","Could use"})
    swap("I find myself wanting",{"I kinda want","Kinda want","I could go for"})
    swap("I find myself rather wanting",{"I kinda want","Kinda want","I could go for"})
    swap("I find myself inclined to",{"Kinda wanna","Might wanna","Feel like I wanna"})
    swap("I feel like I want to",{"Kinda wanna","Might wanna","Feel like I wanna"})
    swap("I want to",{"I wanna","Kinda wanna","I feel like I wanna"})
    swap("I am going to",{"I'm gonna","Gonna","I figure I'll"})
    swap("I have got to",{"I gotta","Gotta","I really gotta"})
    swap("I need to",{"I gotta","Might wanna","Probably gotta"})

    -- Physical / emotional vocabulary.
    swap("I am tired",{"I'm beat","I'm wiped","I'm worn out"})
    swap("I feel tired",{"I'm feeling beat","I'm kinda wiped","Feeling worn out"})
    swap("I am exhausted",{"I'm wiped","I'm completely spent","I'm beat"})
    swap("feels exhausting",{"feels like a real drag","is wearing me down","is draining me"})
    swap("is exhausting",{"is a real drag","is wearing me down","is draining me"})
    swap("is tiring",{"is a drag","is wearing me down","is getting old"})
    swap("rather wearing",{"a bit of a drag","wearing me down","getting old"})
    swap("is tedious",{"is a drag","is getting old","is a pain"})
    swap("rather tedious",{"a real drag","getting old","a pain"})
    swap("is irritating",{"is getting on my nerves","is a real pain","is rubbing me the wrong way"})
    swap("rather irritating",{"getting on my nerves","a real pain","rubbing me the wrong way"})
    swap("distinctly irritating",{"really getting on my nerves","a real pain","rubbing me the wrong way"})
    swap("is uncomfortable",{"doesn't sit right","feels off","isn't sitting right with me"})
    swap("feels uncomfortable",{"feels off","doesn't sit right","feels kinda wrong"})
    swap("rather unpleasant",{"pretty rough","not great","kinda nasty"})
    swap("distinctly unpleasant",{"pretty rough","real nasty","not great"})
    swap("rather difficult",{"pretty rough","a pain to deal with","not easy"})
    swap("difficult to",{"hard to","a pain to","not easy to"})

    -- Vocabulary that makes Street read loose and conversational rather than
    -- merely contracted Casual.
    line=line:gsub("going to","gonna"):gsub("want to","wanna"):gsub("got to","gotta")
    line=line:gsub("kind of","kinda"):gsub("sort of","sorta"):gsub("perhaps","maybe")
    line=line:gsub("somewhat","kinda"):gsub("rather quiet","pretty quiet"):gsub("rather odd","pretty weird"):gsub("considerably","a lot")
    line=line:gsub("particularly","really"):gsub("at present","right now"):gsub("at the moment","right now")
    line=line:gsub("for some time","for a bit"):gsub("of late","lately"):gsub("without invitation","outta nowhere")
    line=line:gsub("without conscious thought","without even thinking"):gsub("without thinking","without even thinking")
    line=line:gsub("no discernible reason","no real reason"):gsub("no particular reason","no real reason")
    line=line:gsub("peculiar","weird"):gsub("tiresome","annoying")
    line=line:gsub("agreeable","nice"):gsub("pleasant","nice"):gsub("occupied","busy")
    line=line:gsub("lingering","hanging around"):gsub("resurfacing","coming back up")
    line=line:gsub("immediately before me","right in front of me")

    -- Street often drops the self-address subject in private thought.
    if chance(.68) then
        line=line:gsub("^I wonder if ","Wonder if "):gsub("^I wonder ","Wonder ")
        line=line:gsub("^I feel like ","Feel like "):gsub("^I keep ","Keep ")
        line=line:gsub("^I can feel ","Can feel "):gsub("^I could use ","Could use ")
        line=line:gsub("^I could ","Could "):gsub("^I have been ","Been ")
    end
    if chance(.48) then
        line=line:gsub("^I am not ","I ain't "):gsub("^I haven't ","Ain't ")
        line=line:gsub("^It isn't ","It ain't "):gsub("^There isn't ","There ain't ")
        line=line:gsub(" isn't "," ain't "):gsub(" aren't "," ain't ")
    end
    return line
end

local VOICE_EXACT={
    street={
        ["Quiet for once."]="Finally, some quiet.",
        ["Nothing is pulling especially hard at the moment."]="Nothing's really pulling at me right now.",
        ["Can't quite settle."]="Can't get settled.",
        ["Stillness is starting to feel uncomfortable."]="Standing still's starting to get old.",
        ["My hands feel too idle."]="Hands are getting way too restless.",
        ["There is a weight behind my eyes."]="Eyes feel heavy.",
        ["Everything feels a little heavier than usual."]="Everything feels heavier than normal.",
        ["Old moments keep resurfacing."]="Old stuff keeps coming back up.",
        ["Hard to fully relax right now."]="Can't really loosen up right now.",
        ["Company doesn't sound half bad right now."]="Wouldn't mind some company right now.",
        ["Everything outside the immediate thing feels quieter."]="Everything else is kinda fading into the background.",
        ["That irritation is still sitting there."]="That irritation's still there, hanging around.",
    },
    refined={
        ["Quiet for once."]="A little quiet, for once.",
        ["Nothing is pulling especially hard at the moment."]="Nothing is demanding much of my attention at the moment.",
        ["Can't quite settle."]="I cannot quite settle.",
        ["Stillness is starting to feel uncomfortable."]="The stillness is beginning to feel uncomfortable.",
        ["My hands feel too idle."]="My hands feel unusually idle.",
        ["There is a weight behind my eyes."]="There is a lingering heaviness behind my eyes.",
        ["Everything feels a little heavier than usual."]="Everything feels somewhat heavier than usual.",
        ["Old moments keep resurfacing."]="Old moments keep returning to mind.",
        ["Hard to fully relax right now."]="It is difficult to fully relax at the moment.",
        ["Company doesn't sound half bad right now."]="Some company would be rather pleasant at the moment.",
        ["Everything outside the immediate thing feels quieter."]="Everything beyond what is immediately in front of me feels quieter.",
        ["That irritation is still sitting there."]="That irritation is still lingering.",
    },
    aristocratic={
        ["Quiet for once."]="At last, a measure of quiet.",
        ["Nothing is pulling especially hard at the moment."]="Nothing appears especially determined to demand my attention at present.",
        ["Can't quite settle."]="I find myself unable to settle completely.",
        ["Stillness is starting to feel uncomfortable."]="This prolonged stillness is becoming rather uncomfortable.",
        ["My hands feel too idle."]="My hands seem intolerably idle at present.",
        ["There is a weight behind my eyes."]="There is a persistent heaviness behind my eyes.",
        ["Everything feels a little heavier than usual."]="Everything feels rather more burdensome than usual.",
        ["Old moments keep resurfacing."]="Old moments insist upon resurfacing.",
        ["Hard to fully relax right now."]="It is proving difficult to relax completely at present.",
        ["Company doesn't sound half bad right now."]="A measure of agreeable company would not be unwelcome at present.",
        ["Everything outside the immediate thing feels quieter."]="Everything beyond the matter immediately before me seems to have receded somewhat.",
        ["That irritation is still sitting there."]="That irritation remains, stubbornly refusing to dissipate.",
    },
}

local function specialVoice(line,voice)
    local out
    if voice=="street" then
        out=phrase(line,"^I wonder what (.-) is up to%.$",{
            "Wonder what %s's up to.","Kinda wonder what %s's gotten into.","What's %s up to, anyway?","Wonder what %s's got going on."
        }) or phrase(line,"^Wonder what (.-) is up to%.$",{
            "Wonder what %s's up to.","Kinda wonder what %s's gotten into.","What's %s up to, anyway?","Wonder what %s's got going on."
        }) or phrase(line,"^I wonder what (.-) is doing right now%.$",{
            "Wonder what %s's doing right now.","What's %s up to right now?","Kinda wonder what %s's doing."
        }) or phrase(line,"^(.+) has crossed my mind a few times lately%.$",{
            "%s's been on my mind a bit lately.","Keep thinking about %s here and there.","%s keeps popping into my head lately."
        }) or phrase(line,"^Wouldn't mind running into (.-) again%.$",{
            "Wouldn't mind bumping into %s again.","Could go for running into %s again.","Wouldn't be bad seeing %s again."
        }) or phrase(line,"^Been a while since I properly caught up with (.-)%.$",{
            "Ain't properly caught up with %s in a while.","Been too long since I caught up with %s.","Haven't really caught up with %s lately."
        }) or phrase(line,"^I haven't heard from (.-) in a bit%.$",{
            "Ain't heard from %s in a bit.","Haven't heard much from %s lately.","%s's been quiet lately."
        }) or phrase(line,"^I could go for (.-)'s company right about now%.$",{
            "Could go for %s's company right now.","Wouldn't mind having %s around right now.","Could use %s around about now."
        }) or phrase(line,"^Something about (.-) still gets under my skin%.$",{
            "Something about %s still rubs me the wrong way.","%s still gets on my nerves.","Still can't quite stand something about %s."
        }) or phrase(line,"^I could do without seeing (.-) for a while%.$",{
            "Could do without seeing %s for a while.","Ain't in any rush to see %s again.","Could happily not run into %s for a bit."
        }) or phrase(line,"^I could use a change of scenery around (.-)%.$",{
            "Could use a change of scenery. Maybe hit %s for a bit.","Kinda wanna get out and wander around %s.","Could go for a change of scenery around %s."
        }) or phrase(line,"^Maybe I should (.-) sometime%.$",{
            "Might wanna %s sometime.","Could maybe %s sometime.","Maybe I'll %s sometime."
        }) or phrase(line,"^I should (.-) sometime%.$",{
            "Might wanna %s sometime.","Could maybe %s sometime.","Maybe I'll %s sometime."
        })
    elseif voice=="refined" then
        out=phrase(line,"^I wonder what (.-) is up to%.$",{
            "I find myself wondering what %s is up to.","I am rather curious what %s has been doing lately.","I wonder what has been occupying %s lately."
        }) or phrase(line,"^Wonder what (.-) is up to%.$",{
            "I find myself wondering what %s is up to.","I am rather curious what %s has been doing lately.","I wonder what has been occupying %s lately."
        }) or phrase(line,"^I wonder what (.-) is doing right now%.$",{
            "I wonder what %s is doing at the moment.","I find myself curious what %s is occupied with just now."
        }) or phrase(line,"^(.+) has crossed my mind a few times lately%.$",{
            "%s has crossed my mind several times lately.","My thoughts have returned to %s more than once lately.","I have found myself thinking of %s with some frequency lately."
        }) or phrase(line,"^Wouldn't mind running into (.-) again%.$",{
            "I would not mind crossing paths with %s again.","Another chance encounter with %s would be rather pleasant.","I would be quite content to see %s again."
        }) or phrase(line,"^Been a while since I properly caught up with (.-)%.$",{
            "It has been some time since I properly caught up with %s.","I have not had a proper conversation with %s in some time.","It has been rather too long since %s and I caught up."
        }) or phrase(line,"^I could go for (.-)'s company right about now%.$",{
            "%s's company would be welcome just now.","I would rather enjoy having %s around at the moment.","Some time in %s's company sounds rather pleasant."
        }) or phrase(line,"^Something about (.-) still gets under my skin%.$",{
            "Something about %s remains distinctly irritating.","I still find something about %s rather grating.","There is something about %s that continues to irritate me."
        }) or phrase(line,"^I could use a change of scenery around (.-)%.$",{
            "A change of scenery around %s might be pleasant.","I think a little time around %s would make for a welcome change of scenery.","Some time around %s might be a refreshing change."
        }) or phrase(line,"^Maybe I should (.-) sometime%.$",{
            "Perhaps I might %s sometime.","I may feel like %s sometime soon.","It might be pleasant to %s sometime."
        }) or phrase(line,"^I should (.-) sometime%.$",{
            "Perhaps I might %s sometime.","I may feel like %s sometime soon.","It might be pleasant to %s sometime."
        })
    elseif voice=="aristocratic" then
        out=phrase(line,"^I wonder what (.-) is up to%.$",{
            "One cannot help but wonder what %s has occupied themselves with.","I confess, I am curious as to what has been occupying %s of late.","It does make one wonder what %s has managed to involve themselves in.","I find myself wondering what curious business has claimed %s's attention lately."
        }) or phrase(line,"^Wonder what (.-) is up to%.$",{
            "One cannot help but wonder what %s has occupied themselves with.","I confess, I am curious as to what has been occupying %s of late.","It does make one wonder what %s has managed to involve themselves in.","I find myself wondering what curious business has claimed %s's attention lately."
        }) or phrase(line,"^I wonder what (.-) is doing right now%.$",{
            "I do wonder what is occupying %s at this very moment.","One is left rather curious as to how %s is spending the present hour.","I find myself wondering what matter has %s's attention just now."
        }) or phrase(line,"^I wonder where (.-) ended up today%.$",{
            "I find myself wondering where %s has managed to end up today.","One does wonder where the day has carried %s.","I am curious where %s's wanderings have taken them today."
        }) or phrase(line,"^(.+) has crossed my mind a few times lately%.$",{
            "%s has found their way into my thoughts with surprising frequency of late.","My thoughts have returned to %s more than once of late.","%s has been an unexpectedly persistent presence in my thoughts lately.","I have found my mind returning to %s rather more often than expected."
        }) or phrase(line,"^Wouldn't mind running into (.-) again%.$",{
            "An unexpected encounter with %s would not be entirely unwelcome.","I would not object to crossing paths with %s again.","Another encounter with %s might prove considerably more pleasant than the usual company."
        }) or phrase(line,"^Been a while since I properly caught up with (.-)%.$",{
            "It has been an unreasonably long while since I properly caught up with %s.","I have been afforded far too little proper conversation with %s of late.","Some time has passed since %s and I last had a conversation worth lingering over."
        }) or phrase(line,"^I haven't heard from (.-) in a bit%.$",{
            "I have heard rather little from %s of late.","%s has been conspicuously absent from my hearing lately.","It has been some time since word of %s reached me."
        }) or phrase(line,"^I could go for (.-)'s company right about now%.$",{
            "%s's company would be considerably more welcome than this silence.","I would rather welcome %s's company at present.","A little time in %s's company would improve the present mood considerably."
        }) or phrase(line,"^Something about (.-) still gets under my skin%.$",{
            "There remains something singularly irritating about %s.","Even the thought of %s manages to test my patience.","%s remains remarkably adept at irritating me without even being present.","I continue to find something about %s profoundly tiresome."
        }) or phrase(line,"^I could do without seeing (.-) for a while%.$",{
            "I would consider a prolonged absence of %s entirely agreeable.","I have no particular desire to suffer %s's company for some time.","A respectable distance from %s would suit me perfectly well for now."
        }) or phrase(line,"^I could use a change of scenery around (.-)%.$",{
            "A change of scenery would be welcome; perhaps some time around %s might prove less tedious than remaining here.","I have grown rather tired of this view. Some time around %s might prove a pleasant diversion.","Some time around %s would at least offer a more agreeable change of scenery."
        }) or phrase(line,"^Maybe I should (.-) sometime%.$",{
            "Perhaps I might indulge in the notion and %s sometime.","I may yet find the inclination to %s before long.","The prospect of taking some time to %s is not without appeal."
        }) or phrase(line,"^I should (.-) sometime%.$",{
            "Perhaps I might indulge in the notion and %s sometime.","I may yet find the inclination to %s before long.","The prospect of taking some time to %s is not without appeal."
        }) or phrase(line,"^I could use (.-)%.$",{
            "I would rather welcome %s.","%s would be distinctly welcome at present.","I find myself rather in want of %s."
        })
    end
    return out
end

local function applyVoice(line,voice)
    line=tostring(line or"");voice=voice or"casual";if voice=="natural"then voice="casual"end
    local exact=VOICE_EXACT[voice]and VOICE_EXACT[voice][line]
    if exact then line=exact else local special=specialVoice(line,voice);if special then line=special end end
    if voice=="street"then
        line=contractCasual(line)
        line=applyStreetSlang(line)
    elseif voice=="casual"then
        line=contractCasual(line)
    elseif voice=="refined"then
        line=expandContractions(line)
        line=line:gsub("^Wonder ","I wonder "):gsub("^Been a while ","It has been a while "):gsub("^Could use ","I could use ")
        line=line:gsub("kinda","rather"):gsub("sorta","somewhat"):gsub("a bit","somewhat"):gsub("pretty rough","rather difficult")
        line=line:gsub("very fast","remarkably swift"):gsub("very slow","notably sluggish"):gsub("really fast","quite swift")
        line=line:gsub("weird","peculiar"):gsub("annoying","irritating"):gsub("dull","tedious"):gsub("busy","occupied")
        line=line:gsub("a little tiring","rather wearing"):gsub("really tiring","quite exhausting"):gsub("pretty good","rather pleasant")
        line=line:gsub("right now","at the moment"):gsub("no particular reason","no clear reason"):gsub("without thinking","without conscious thought")
        line=line:gsub("hard to","difficult to"):gsub("these days","lately"):gsub("again and again","repeatedly")
        line=line:gsub("^Funny how ","It is curious how "):gsub("^I think ","I find myself thinking ")
    elseif voice=="aristocratic"then
        line=expandContractions(line)
        line=line:gsub("^Wonder ","I find myself wondering "):gsub("^Been a while ","It has been some time "):gsub("^Could use ","I would rather welcome ")
        line=line:gsub("Maybe ","Perhaps "):gsub("kinda","rather"):gsub("sorta","somewhat"):gsub("a bit","somewhat")
        line=line:gsub("weird","peculiar"):gsub("strange","curious"):gsub("annoying","tiresome"):gsub("dull","tedious"):gsub("busy","occupied")
        line=line:gsub("pretty good","quite agreeable"):gsub("pretty rough","distinctly unpleasant"):gsub("very fast","remarkably swift"):gsub("very slow","painfully sluggish")
        line=line:gsub("right now","at present"):gsub("at the moment","at present"):gsub("for a while","for some time")
        line=line:gsub("hard to","difficult to"):gsub("without thinking","without conscious thought"):gsub("no particular reason","no discernible reason")
        line=line:gsub("these days","of late"):gsub("lately","of late"):gsub("again and again","repeatedly"):gsub("every so often","from time to time")
        line=line:gsub("tiredness","weariness"):gsub("tired","weary"):gsub("annoyed","irritated"):gsub("right in front of me","immediately before me")
        line=line:gsub("^Funny how ","It is curious how "):gsub("^I think ","It occurs to me that "):gsub("^I wonder if ","One does wonder whether ")
        line=line:gsub("^I keep thinking about ","My thoughts persist in returning to "):gsub("^I can feel ","I am distinctly aware that ")
        line=line:gsub("I don't know","I cannot quite say"):gsub("I do not know","I cannot quite say")
        line=line:gsub("I want to ","I find myself inclined to "):gsub("I want ","I find myself rather wanting ")
    end
    return line
end

local function addOption(options,line,v)
    if v and v~=line then options[#options+1]=v end
end

local function applyProfanity(line,level,voice)
    local def=PROFANITY[level or"none"]or PROFANITY.none
    if def.chance<=0 or math.random()>def.chance then return line end
    voice=(voice=="natural"or not VOICES[voice])and"casual"or voice
    local strong=level=="frequent"or level=="heavy"
    local heavy=level=="heavy"
    local options={};local v

    local function option(pattern,replacement)
        v=line:gsub(pattern,replacement,1);addOption(options,line,v)
    end

    -- Clause-level emphasis.  Strong profanity can interrupt a question where
    -- a real speaker would naturally stress it; lighter levels prefer damn /
    -- bloody rather than forcing an F-word into neutral curiosity.
    if strong then
        option("^Wonder what (.-)'s up to%.$","Wonder what the fuck %1's up to.")
        option("^Kinda wonder what (.-)'s gotten into%.$","Kinda wonder what the fuck %1's gotten into.")
        option("^What's (.-) up to, anyway%?$","What the fuck's %1 up to, anyway?")
        option("^I wonder what (.-) is up to%.$","I wonder what the fuck %1 is up to.")
        option("^I find myself wondering what (.-) is up to%.$","I find myself wondering what the fuck %1 is up to.")
        option("^One cannot help but wonder what (.-) has occupied themselves with%.$","One cannot help but wonder what the fuck %1 has occupied themselves with.")
        option("^I wonder where (.-) has gotten to%.$","I wonder where the fuck %1 has gotten to.")
        option("^Wonder where (.-) got to%.$","Wonder where the fuck %1 got to.")
        option("^I wonder why (.-) ","I wonder why the fuck %1 ")
        option("^Wonder why (.-) ","Wonder why the fuck %1 ")
    end

    -- Register-specific profanity changes vocabulary and idiom, not merely an
    -- adjective suffix.  Street in particular prefers piss / shit / pain in
    -- the ass / no clue constructions.  Refined and Aristocratic keep their
    -- syntax while allowing the character's chosen profanity level through.
    if voice=="street" then
        option("I got no clue",strong and"I got no fucking clue"or"I got no damn clue")
        option("Got no real clue",strong and"Got no fucking clue"or"Got no damn clue")
        option("Got no clue",strong and"Got no fucking clue"or"Got no damn clue")
        option("No clue",strong and"No fucking clue"or"No damn clue")
        option("I ain't got a clue",strong and"I ain't got a fucking clue"or"I ain't got a damn clue")
        option("Couldn't tell you",strong and"Couldn't fucking tell you"or"Couldn't damn well tell you")
        option("getting on my nerves",strong and"pissing me the fuck off"or"pissing me off")
        option("really getting on my nerves",strong and"really pissing me the fuck off"or"really pissing me off")
        option("rubbing me the wrong way",strong and"pissing me off"or"getting on my damn nerves")
        option("a real pain",heavy and"a pain in the fucking ass"or"a pain in the ass")
        option("a pain to deal with",heavy and"a fucking pain to deal with"or"a damn pain to deal with")
        option("is a pain",heavy and"is a pain in the fucking ass"or"is a pain in the ass")
        option("is a real drag",strong and"is a fucking drag"or"is a damn drag")
        option("is a drag",strong and"is a fucking drag"or"is a damn drag")
        option("is getting old",strong and"is getting fucking old"or"is getting damn old")
        option("feels off",heavy and"feels fucked up"or"feels damn off")
        option("feels kinda wrong",heavy and"feels fucking wrong"or"feels damn wrong")
        option("doesn't sit right",strong and"doesn't fucking sit right"or"doesn't sit damn right")
        option("pretty rough",heavy and"rough as shit"or"damn rough")
        option("real nasty",heavy and"nasty as shit"or"damn nasty")
        option("not great",strong and"pretty fucking bad"or"damn bad")
        option("I'm beat",heavy and"I'm fucking wiped"or"I'm damn beat")
        option("I'm wiped",heavy and"I'm fucking wiped"or"I'm damn wiped")
        option("worn out",strong and"fucking worn out"or"damn worn out")
        option("Could use ",strong and"Could fucking use "or"Could damn well use ")
        option("Could really go for ",strong and"Could really fucking go for "or"Could damn well go for ")
        option("Kinda want ",strong and"Kinda fucking want "or"Kinda damn well want ")
        option("I wanna ",strong and"I fucking wanna "or"I damn well wanna ")
        option("I hate ","I fucking hate ")
        option("can't stand ","can't fucking stand ")
        option("don't care about ",heavy and"don't give a shit about "or"don't give a damn about ")
        option("don't care much about ",heavy and"don't give much of a shit about "or"don't give a damn about ")
        option("messed up",strong and"fucked up"or"damn messed up")
    elseif voice=="casual" then
        option("I don't know",strong and"I have no fucking idea"or"I have no damn idea")
        option("I have no idea",strong and"I have no fucking idea"or"I have no damn idea")
        option("gets under my skin",strong and"pisses me off"or"gets on my damn nerves")
        option("gets on my nerves",strong and"really pisses me off"or"gets on my damn nerves")
        option("still bothers me",strong and"still pisses me off"or"still damn well bothers me")
        option("annoying",strong and"fucking annoying"or"damn annoying")
        option("irritating",strong and"fucking irritating"or"damn irritating")
        option("exhausting",strong and"fucking exhausting"or"damn exhausting")
        option("tiring",strong and"fucking tiring"or"damn tiring")
        option("difficult",strong and"fucking difficult"or"damn difficult")
        option("Could use ",strong and"Could really fucking use "or"Could damn well use ")
        option("I could use ",strong and"I could really fucking use "or"I could damn well use ")
        option("I miss ",strong and"I fucking miss "or"I really damn miss ")
        option("I hate ","I fucking hate ")
        option("this silence",strong and"this fucking silence"or"this damn silence")
        option("this view",strong and"this fucking view"or"this damn view")
    elseif voice=="refined" then
        option("I do not know",strong and"I have no fucking idea"or"I have no damned idea")
        option("I cannot quite say",strong and"I have no fucking idea"or"I have no damned idea")
        option("rather irritating",heavy and"a fucking nuisance"or"damnably irritating")
        option("distinctly irritating",heavy and"a fucking nuisance"or"damnably irritating")
        option("rather grating",heavy and"fucking grating"or"damnably grating")
        option("rather difficult",heavy and"fucking difficult"or"damned difficult")
        option("quite exhausting",heavy and"fucking exhausting"or"bloody exhausting")
        option("rather wearing",heavy and"fucking exhausting"or"bloody wearing")
        option("rather unpleasant",heavy and"fucking unpleasant"or"damned unpleasant")
        option("I would not mind",strong and"I would not fucking mind"or"I would not damn well mind")
        option("I could use",strong and"I could fucking use"or"I could damn well use")
        option("continues to irritate me",strong and"continues to piss me off"or"continues to damn well irritate me")
    elseif voice=="aristocratic" then
        option("I cannot quite say",heavy and"I haven't the faintest fucking idea"or"I haven't the faintest damned idea")
        option("I find myself unable to",heavy and"I find myself fucking unable to"or"I find myself damned unable to")
        option("singularly irritating",heavy and"a fucking nuisance"or"damnably irritating")
        option("profoundly tiresome",heavy and"fucking tiresome"or"bloody tiresome")
        option("rather more burdensome",heavy and"fucking burdensome"or"damnably burdensome")
        option("distinctly unpleasant",heavy and"fucking unpleasant"or"damned unpleasant")
        option("entirely agreeable",heavy and"fucking agreeable"or"damned agreeable")
        option("not be entirely unwelcome",heavy and"not be fucking unwelcome"or"not be damned unwelcome")
        option("rather welcome",heavy and"fucking welcome"or"damnably welcome")
        option("rather tired",heavy and"fucking tired"or"bloody tired")
        option("continues to irritate me",heavy and"continues to fucking infuriate me"or"continues to damnably irritate me")
        option("test my patience",heavy and"test my fucking patience"or"test my damned patience")
    end

    -- Shared natural phrase swaps catch raw lines that did not match a
    -- register-specific construction.  They intentionally target meaning,
    -- rather than blindly tacking a swear onto the end of the sentence.
    option("gets under my skin",strong and"gets on my fucking nerves"or"gets on my damn nerves")
    option("still bothers me",strong and"still pisses me off"or"still damn well bothers me")
    option("not worth the trouble",strong and"not worth the fucking trouble"or"not worth the damn trouble")
    option("old irritation",strong and"old fucking irritation"or"old damn irritation")
    option("headache",strong and"fucking headache"or"damn headache")
    option("a change of scenery",strong and"a fucking change of scenery"or"a damn change of scenery")
    option("very ",strong and"fucking "or"damn ")
    option("really ",strong and"fucking "or"damn ")

    if #options>0 then return options[math.random(1,#options)] end
    return line
end

local function applyLearnedVoiceMarkers(line,d,profile,voice)
    -- Dropped-g/ya/yer markers belong to Street/Casual rendering. They are
    -- learned from the character's own speech, but never decorate an effective
    -- Refined or Aristocratic register.
    if voice~="street"and voice~="casual"then return line end
    if not d or not d.voiceLearning.enabled or not profile or (profile.lines or 0)<12 then return line end
    local a=profile.stats or {};local function ratio(n,den)return (tonumber(n)or 0)/math.max(1,tonumber(den)or 0)end
    local drop=ratio(a.dropG,a.ingEligible)
    if (a.dropG or 0)>=4 and drop>=.28 then
        local p=math.min(.82,.24+drop*.66)
        local pairs={{"going","goin'"},{"doing","doin'"},{"trying","tryin'"},{"thinking","thinkin'"},{"wondering","wonderin'"},{"feeling","feelin'"},{"looking","lookin'"},{"getting","gettin'"},{"keeping","keepin'"},{"sitting","sittin'"},{"standing","standin'"},{"talking","talkin'"},{"waiting","waitin'"},{"missing","missin'"},{"remembering","rememberin'"},{"bothering","botherin'"}}
        for _,r in ipairs(pairs)do if line:find(r[1],1,true) and math.random()<p then line=line:gsub(r[1],r[2],1)end end
    end
    if (a.ya or 0)>=4 and ratio(a.ya,a.you)>=.35 and math.random()<.58 then line=line:gsub("%f[%a]you%f[%A]","ya") end
    if (a.yer or 0)>=4 and ratio(a.yer,a.your)>=.35 and math.random()<.58 then line=line:gsub("%f[%a]your%f[%A]","yer") end
    if (a.em or 0)>=4 and ratio(a.em,a.them)>=.32 and math.random()<.56 then line=line:gsub("%f[%a]them%f[%A]","'em") end
    return line
end
function RPAL.StylePersonaThought(line)
    local d=ensureLiving();if not d then return line end
    local voice,profile=RPAL.GetPersonaEffectiveVoice()
    local styled=applyVoice(line,voice)
    styled=applyLearnedVoiceMarkers(styled,d,profile,voice)
    local profanity=RPAL.GetPersonaEffectiveProfanity and RPAL.GetPersonaEffectiveProfanity() or d.profanity
    return applyProfanity(styled,profanity,voice)
end

-- Wrap the stable thought generator rather than replacing its learned weighting.
-- Living inputs get a chance to speak first, then the established engine remains
-- the fallback and receives only a voice/profanity rendering pass.
local function rawThoughtWasRecent(p,line)
    line=tostring(line or"");if line==""then return false end
    local h=type(p and p.history)=="table"and p.history or{}
    for i=math.max(1,#h-99),#h do
        local r=h[i]
        if type(r)=="table"and(tostring(r.rawText or"")==line or tostring(r.text or"")==line)then return true end
    end
    return false
end

local baseGenerate=RPAL.GeneratePersonalityThought
if baseGenerate then
    RPAL.GeneratePersonalityThought=function(context)
        local d,p=ensureLiving();if not d or not p then return baseGenerate(context)end
        local line,category,source,mind
        if tostring(context or"")~="preview"and math.random()<.22 then line,category,source,mind=injuryThought()end
        if not line and tostring(context or"")~="preview"and math.random()<.22 then line,category,source,mind=relationshipThought()end
        if not line and math.random()<.16 then
            local mk=moralKind()
            if RPAL.GeneratePersonaMoralThought then line,mind=RPAL.GeneratePersonaMoralThought(mk,p)end
            if not line then
                local pool=MORAL_GENERAL[mk];local fresh={}
                for _,candidate in ipairs(pool)do if not rawThoughtWasRecent(p,candidate)then fresh[#fresh+1]=candidate end end
                local use=#fresh>0 and fresh or pool;line=use[math.random(1,#use)]
            end
            category="reflective";source="moral-"..mk
        end
        if line and rawThoughtWasRecent(p,line)then line,category,source=nil,nil,nil end
        if not line then
            local raw,row=baseGenerate(context)
            if not raw then return raw,row end
            local styled=RPAL.StylePersonaThought(raw)
            if row then local effectiveProf=RPAL.GetPersonaEffectiveProfanity and RPAL.GetPersonaEffectiveProfanity() or d.profanity;row.rawText=row.rawText or raw;row.text=styled;row.voice=(RPAL.GetPersonaEffectiveVoice and RPAL.GetPersonaEffectiveVoice())or d.voice;row.profanity=effectiveProf;row.profanityBaseline=d.profanity end
            return styled,row
        end
        local rawLine=line
        line=RPAL.StylePersonaThought(rawLine)
        local effectiveProf=RPAL.GetPersonaEffectiveProfanity and RPAL.GetPersonaEffectiveProfanity() or d.profanity
        local row={text=line,rawText=rawLine,category=category or"thought",source=source or"living-persona",at=time(),context=tostring(context or"ambient"),voice=(RPAL.GetPersonaEffectiveVoice and RPAL.GetPersonaEffectiveVoice())or d.voice,profanity=effectiveProf,profanityBaseline=d.profanity,mind=mind}
        p.history=type(p.history)=="table"and p.history or{};p.history[#p.history+1]=row;while#p.history>120 do table.remove(p.history,1)end
        return line,row
    end
end

-- ------------------------------- URGE UI ----------------------------------
local urgeFrame
local function playerDisplayName()
    if RPAL.FullName then local ok,v=pcall(RPAL.FullName);if ok and trim(v)~=""then return trim(v)end end
    return(UnitName and UnitName("player"))or"Your character"
end

-- Pronoun-aware third-person rendering for impulse and TRP Currently text.
-- Only explicit profile/custom-field data is considered: names, race models and
-- UnitSex are deliberately ignored. Ambiguous or missing data stays they/them.
local PRONOUN_SETS={
    masculine={kind="masculine",subject="he",object="him",possessiveDeterminer="his",possessivePronoun="his",reflexive="himself",be="is",pastBe="was",have="has",doVerb="does"},
    feminine={kind="feminine",subject="she",object="her",possessiveDeterminer="her",possessivePronoun="hers",reflexive="herself",be="is",pastBe="was",have="has",doVerb="does"},
    neutral={kind="neutral",subject="they",object="them",possessiveDeterminer="their",possessivePronoun="theirs",reflexive="themselves",be="are",pastBe="were",have="have",doVerb="do"},
}
local function explicitPronounKind(value)
    local raw=trim(value);if raw==""then return nil end
    local s=raw:lower():gsub("’","'"):gsub("[_%-]+"," ")
    local candidates={}
    local function mark(kind,patterns)
        local first
        for _,pat in ipairs(patterns)do local at=s:find(pat);if at and(not first or at<first)then first=at end end
        if first then candidates[#candidates+1]={kind=kind,at=first}end
    end
    mark("feminine",{"%f[%a]she%f[%A]","%f[%a]her%f[%A]","%f[%a]hers%f[%A]","%f[%a]female%f[%A]","%f[%a]woman%f[%A]","%f[%a]feminine%f[%A]"})
    mark("masculine",{"%f[%a]he%f[%A]","%f[%a]him%f[%A]","%f[%a]his%f[%A]","%f[%a]male%f[%A]","%f[%a]man%f[%A]","%f[%a]masculine%f[%A]"})
    mark("neutral",{"%f[%a]they%f[%A]","%f[%a]them%f[%A]","%f[%a]theirs%f[%A]","%f[%a]nonbinary%f[%A]","%f[%a]non binary%f[%A]","%f[%a]enby%f[%A]"})
    table.sort(candidates,function(a,b)return a.at==b.at and a.kind<b.kind or a.at<b.at end)
    return candidates[1]and candidates[1].kind or nil
end
local function explicitProfilePronouns()
    local direct={
        {value=RPAL.char and RPAL.char.profile and RPAL.char.profile.pronouns,source="ODYN profile.pronouns"},
        {value=RPAL.char and RPAL.char.profile and RPAL.char.profile.gender,source="ODYN profile.gender"},
        {value=RPAL.char and RPAL.char.pronouns,source="ODYN character.pronouns"},
    }
    for _,row in ipairs(direct)do if explicitPronounKind(row.value)then return row.value,row.source end end

    local api=_G.TRP3_API;local profile=type(api)=="table"and api.profile or nil;local roots={}
    if type(profile)=="table"and type(profile.getData)=="function"then
        for _,path in ipairs({"player/character","player/characteristics"})do local ok,v=pcall(profile.getData,path);if ok and type(v)=="table"then roots[#roots+1]=v end end
    end
    local seen={}
    local function walk(node,depth)
        if type(node)~="table"or depth>5 or seen[node]then return nil end;seen[node]=true
        local label=trim(node.NA or node.name or node.label or node.title):lower()
        if label:find("pronoun",1,true)or label=="gender"or label=="sex"then
            for _,key in ipairs({"VA","value","text","TX","data"})do if explicitPronounKind(node[key])then return node[key],"TRP explicit field"end end
        end
        for key,value in pairs(node)do
            local k=tostring(key):lower()
            if k:find("pronoun",1,true)or k=="gender"or k=="sex"then
                if explicitPronounKind(value)then return value,"TRP "..tostring(key)end
                if type(value)=="table"then for _,vk in ipairs({"VA","value","text","TX","data"})do if explicitPronounKind(value[vk])then return value[vk],"TRP "..tostring(key)end end end
            end
        end
        for _,value in pairs(node)do if type(value)=="table"then local v,src=walk(value,depth+1);if v then return v,src end end end
    end
    for _,root in ipairs(roots)do local value,source=walk(root,1);if value then return value,source end end
end
function RPAL.GetPersonaPronounSet()
    local raw,source=explicitProfilePronouns();local kind=explicitPronounKind(raw)or"neutral";local base=PRONOUN_SETS[kind]or PRONOUN_SETS.neutral;local out={}
    for key,value in pairs(base)do out[key]=value end
    out.explicit=raw~=nil;out.raw=raw;out.source=source or"neutral fallback"
    return out
end
local function upperFirst(value)return tostring(value or""):gsub("^%l",string.upper)end
local function replaceWord(text,word,replacement)
    text=text:gsub("%f[%a]"..upperFirst(word).."%f[%A]",upperFirst(replacement))
    return text:gsub("%f[%a]"..word.."%f[%A]",replacement)
end
function RPAL.PersonalizePersonaPronouns(value)
    local set=RPAL.GetPersonaPronounSet();local text=tostring(value or"")
    local phrases={
        {"they are",set.subject.." "..set.be},{"they were",set.subject.." "..set.pastBe},{"they have",set.subject.." "..set.have},{"they do",set.subject.." "..set.doVerb},
        {"They are",upperFirst(set.subject).." "..set.be},{"They were",upperFirst(set.subject).." "..set.pastBe},{"They have",upperFirst(set.subject).." "..set.have},{"They do",upperFirst(set.subject).." "..set.doVerb},
    }
    for _,row in ipairs(phrases)do text=text:gsub(row[1],row[2])end
    text=replaceWord(text,"themselves",set.reflexive);text=replaceWord(text,"themself",set.reflexive)
    text=replaceWord(text,"theirs",set.possessivePronoun);text=replaceWord(text,"their",set.possessiveDeterminer)
    text=replaceWord(text,"them",set.object);text=replaceWord(text,"they",set.subject)
    return text,set
end
function RPAL.ShowPersonaUrge(message,activityLabel)
    if RPAL.DMKit and RPAL.DMKit.ShouldSuppressProgressionUI and RPAL.DMKit.ShouldSuppressProgressionUI()then return end
    if not urgeFrame then
        local f=CreateFrame("Frame","ODYN_PersonaUrge",UIParent,"BackdropTemplate");urgeFrame=f;f:SetSize(620,92);f:SetPoint("TOP",UIParent,"TOP",0,-300);f:SetFrameStrata("FULLSCREEN_DIALOG");f:SetFrameLevel(9290);f:EnableMouse(false);f:Hide()
        f:SetBackdrop({bgFile=WHITE,edgeFile=WHITE,edgeSize=1});f:SetBackdropColor(.012,.014,.020,.88);f:SetBackdropBorderColor(.23,.40,.43,.72)
        f.line=f:CreateTexture(nil,"ARTWORK");f.line:SetTexture(WHITE);f.line:SetSize(180,1);f.line:SetPoint("TOP",0,-26);f.line:SetVertexColor(.34,.78,.76,.55)
        f.kicker=f:CreateFontString(nil,"OVERLAY");f.kicker:SetFont(FONT,7.5,"");f.kicker:SetTextColor(.46,.72,.72,1);f.kicker:SetPoint("TOP",0,-11);f.kicker:SetText("A SMALL IMPULSE")
        f.body=f:CreateFontString(nil,"OVERLAY");f.body:SetFont(FONT,14,"");f.body:SetTextColor(.88,.91,.92,1);f.body:SetPoint("TOP",0,-36);f.body:SetWidth(570);f.body:SetJustifyH("CENTER");f.body:SetWordWrap(true)
        f.meta=f:CreateFontString(nil,"OVERLAY");f.meta:SetFont(FONT,7,"");f.meta:SetTextColor(.42,.48,.52,1);f.meta:SetPoint("BOTTOM",0,9)
    end
    local rendered=RPAL.PersonalizePersonaPronouns(message);RPAL._lastPersonaUrgeText=rendered
    local f=urgeFrame;f.body:SetText(rendered);f.meta:SetText(string.upper(tostring(activityLabel or"IDLE HABIT")));f:SetAlpha(0);f._age=0;f:Show()
    f:SetScript("OnUpdate",function(self,elapsed)
        self._age=(self._age or 0)+(elapsed or 0);local t=self._age;local a=math.min(1,t/.45);if t>5.0 then a=math.max(0,1-((t-5.0)/1.0))end;self:SetAlpha(a)
        if t>=6.0 then self:Hide();self:SetScript("OnUpdate",nil)end
    end)
end

-- -------------------------- TRP CURRENTLY ---------------------------------
local currentOwnedLine=nil
local currentExpiresAt=0
local function trpCharacter()
    local api=_G.TRP3_API;local profile=type(api)=="table"and api.profile or nil;if type(profile)~="table"or type(profile.getData)~="function"then return nil end
    local ok,character=pcall(profile.getData,"player/character");if ok and type(character)=="table"then return character end;return nil
end
local function notifyTRPCharacter(value)
    if type(_G.msp)=="table"and type(_G.msp.my)=="table"then
        _G.msp.my.CU=value
        if type(_G.msp.Update)=="function"then pcall(_G.msp.Update,_G.msp)end
    end
    local addon=_G.TRP3_Addon;local api=_G.TRP3_API;local events=type(addon)=="table"and addon.Events or nil;local playerID=type(api)=="table"and type(api.globals)=="table"and api.globals.player_id or nil;local eventName=type(events)=="table"and events.REGISTER_DATA_UPDATED or nil
    if type(addon)=="table"and type(addon.TriggerEvent)=="function"and eventName and playerID then pcall(addon.TriggerEvent,addon,eventName,playerID,true,"character")end
end
local function splitFirst(text)
    text=tostring(text or""):gsub("\r\n","\n"):gsub("\r","\n")
    local first,rest=text:match("^([^\n]*)\n?(.*)$");return first or"",rest or""
end
local function setCurrentlyActivity(action,duration)
    local d=ensureLiving();if not d or not d.idle.writeCurrently then return false end
    local c=trpCharacter();if not c then return false end
    local current=tostring(c.CU or"");local first,rest=splitFirst(current);local rendered=RPAL.PersonalizePersonaPronouns(trim(action));local own="Currently: "..rendered
    local new
    if first:lower():match("^currently%s*:")then
        new=own..(rest~=""and("\n"..rest)or"")
    elseif current~=""then new=own.."\n"..current
    else new=own end
    c.CU=new;currentOwnedLine=own;currentExpiresAt=(GetTime and GetTime()or 0)+(tonumber(duration)or 55);notifyTRPCharacter(new);return true
end
local function clearOwnedCurrently()
    if not currentOwnedLine then return false end
    local c=trpCharacter();if not c then currentOwnedLine=nil;currentExpiresAt=0;return false end
    local current=tostring(c.CU or"");local first,rest=splitFirst(current)
    -- Never restore a previous snapshot. Remove only our exact current first line.
    -- If the player edited it in the meantime, ownership is relinquished.
    if first==currentOwnedLine then
        c.CU=rest;notifyTRPCharacter(rest)
    end
    currentOwnedLine=nil;currentExpiresAt=0;return true
end
RPAL.SetPersonaCurrentlyActivity=setCurrentlyActivity
RPAL.ClearPersonaCurrentlyActivity=clearOwnedCurrently
local function selectedIdleKeys(d)
    local out={};for _,key in ipairs(IDLE_ORDER)do if d.idle.selected[key]and IDLES[key]then out[#out+1]=key end end;return out
end
local function canIdle()
    if RPAL.IsPersonaSystemEnabled and not RPAL.IsPersonaSystemEnabled()then return false end
    if RPAL.IsPersonalityCalibrated and not RPAL.IsPersonalityCalibrated()then return false end
    if InCombatLockdown and InCombatLockdown()then return false end
    -- Active Session/chat is deliberately NOT required. IC status alone is the
    -- gate, so a character can feel alive while the player is simply waiting.
    if RPAL.IsTRPInCharacter then local ok,res=pcall(RPAL.IsTRPInCharacter);return ok and res and true or false end
    return false
end
local function scheduleNextIdle(d,fresh)
    local now=GetTime and GetTime()or 0
    -- Exact requested cadence: each next impulse is independently rolled in a
    -- 5–15 minute window, including the first one after entering IC.
    d.state.nextIdleAt=now+math.random(5*60,15*60)
end
local function tryIdle()
    local d=ensureLiving();if not d or not d.idle.enabled then return end
    local keys=selectedIdleKeys(d);if#keys==0 then return end
    local now=GetTime and GetTime()or 0
    -- Do not let an OOC timer leak into IC. The 5–15 minute window begins
    -- only once the character is actually IC.
    if not canIdle()then d.state.nextIdleAt=0;return end
    if d.state.nextIdleAt<=0 then scheduleNextIdle(d,true);return end
    if now<d.state.nextIdleAt then return end
    local choices={};for _,key in ipairs(keys)do if key~=d.state.lastIdleKey or#keys==1 then choices[#choices+1]=key end end;if#choices==0 then choices=keys end
    local key=choices[math.random(1,#choices)];local def=IDLES[key];d.state.lastIdleKey=key;scheduleNextIdle(d,false)
    local urge=def.urge[math.random(1,#def.urge)];RPAL.ShowPersonaUrge(playerDisplayName().." "..urge,def.label)
    setCurrentlyActivity(def.currently,math.random(42,78))
end

local ticker=CreateFrame("Frame")
ticker:RegisterEvent("PLAYER_LOGIN")
ticker:RegisterEvent("PLAYER_ENTERING_WORLD")
ticker:SetScript("OnEvent",function()local d=ensureLiving();if d then d.state.nextIdleAt=0 end end)
local function PersonaIdleTick()
    local now=GetTime and GetTime()or 0
    if currentOwnedLine and currentExpiresAt>0 and now>=currentExpiresAt then clearOwnedCurrently()end
    tryIdle()
end
if C_Timer and C_Timer.NewTicker then
    C_Timer.NewTicker(15,PersonaIdleTick)
else
    local accum=0
    ticker:SetScript("OnUpdate",function(_,elapsed)
        accum=accum+(elapsed or 0);if accum<15 then return end;accum=0
        PersonaIdleTick()
    end)
end

-- ---------------------------- CONFIG UI -----------------------------------
local configFrame
local C={bg={.010,.009,.022,.99},panel={.025,.020,.046,.99},panel2={.036,.028,.060,.99},border={.30,.23,.47,.95},gold={.90,.70,.34,1},pale={.94,.95,.98,1},muted={.68,.70,.78,1},teal={.33,.78,.76,1},green={.28,.82,.50,1}}
local function backdrop(f,bg,border)if not f.SetBackdrop then return end;f:SetBackdrop({bgFile=WHITE,edgeFile=WHITE,edgeSize=1});f:SetBackdropColor(bg[1],bg[2],bg[3],bg[4]or 1);f:SetBackdropBorderColor(border[1],border[2],border[3],border[4]or 1)end
local function text(parent,value,size,color)local fs=parent:CreateFontString(nil,"OVERLAY");fs:SetFont(FONT,size or 12,"");fs:SetText(tostring(value or""));color=color or C.pale;fs:SetTextColor(color[1],color[2],color[3],color[4]or 1);return fs end
local refreshConfig
local function button(parent,label,w,h)
    local b=CreateFrame("Button",nil,parent,"BackdropTemplate")
    b:SetSize(w or 120,h or 30);backdrop(b,C.panel2,C.border)
    b.label=text(b,label,11.5,C.pale);b.label:SetPoint("CENTER")
    b:SetScript("OnEnter",function(self)
        if self._locked then return end
        self._odynHover=true
        self:SetBackdropBorderColor(C.gold[1],C.gold[2],C.gold[3],1)
        if refreshConfig then refreshConfig() end
    end)
    b:SetScript("OnLeave",function(self)
        if self._locked then return end
        self._odynHover=false
        self:SetBackdropBorderColor(C.border[1],C.border[2],C.border[3],C.border[4]or 1)
        if refreshConfig then refreshConfig() end
    end)
    return b
end
refreshConfig=function()
    local f=configFrame;local d=ensureLiving();if not f or not d then return end
    local locked=livingCalibrationLocked()
    for _,b in ipairs(f.voiceButtons or{})do
        local on=b.value==d.voice
        b._locked=locked;b._odynHover=false;if locked then b:Disable()else b:Enable()end
        local border=on and C.teal or C.border
        b:SetBackdropColor(C.panel2[1],C.panel2[2],C.panel2[3],on and .98 or .62)
        b:SetBackdropBorderColor(border[1],border[2],border[3],on and 1 or (locked and .28 or .46))
        b.label:SetTextColor(on and C.teal[1] or C.muted[1],on and C.teal[2] or C.muted[2],on and C.teal[3] or C.muted[3],on and 1 or (locked and .40 or .62))
    end
    for _,b in ipairs(f.profanityButtons or{})do
        local on=b.value==d.profanity
        b._locked=locked;b._odynHover=false;if locked then b:Disable()else b:Enable()end
        local border=on and C.teal or C.border
        b:SetBackdropColor(C.panel2[1],C.panel2[2],C.panel2[3],on and .98 or .62)
        b:SetBackdropBorderColor(border[1],border[2],border[3],on and 1 or (locked and .28 or .46))
        b.label:SetTextColor(on and C.teal[1] or C.muted[1],on and C.teal[2] or C.muted[2],on and C.teal[3] or C.muted[3],on and 1 or (locked and .40 or .62))
    end
    for _,b in ipairs(f.idleButtons or{})do local on=d.idle.selected[b.key]and true or false;local border=b._odynHover and C.gold or(on and C.green or C.border);b:SetBackdropBorderColor(unpack(border));b.state:SetText(on and"ON"or"OFF");b.state:SetTextColor(unpack(on and C.green or C.muted))end
    if f.currently then local on=d.idle.writeCurrently;local border=f.currently._odynHover and C.gold or(on and C.green or C.border);f.currently:SetBackdropBorderColor(unpack(border));f.currently.label:SetText(on and"TRP CURRENTLY: ON"or"TRP CURRENTLY: OFF")end
    if f.adaptive then
        f.adaptive._locked=true;f.adaptive._odynHover=false;f.adaptive:Disable()
        local on=d.voiceLearning.enabled;local border=on and C.green or C.border
        f.adaptive:SetBackdropBorderColor(unpack(border));f.adaptive.label:SetText(on and"ADAPTIVE: AUTOMATIC"or"ADAPTIVE: PENDING")
        f.adaptive.label:SetTextColor(unpack(on and C.green or C.muted))
    end
    if f.sub then
        if locked then
            f.sub:SetText("Voice and Profanity are the starting baselines chosen during calibration and are now read-only. ODYN automatically follows sustained changes in this character's own IC speech without learning another player's style.")
        else
            f.sub:SetText("Choose Voice and Profanity as calibration starting points. They become read-only when calibration finishes; ODYN then follows sustained changes in this character's own IC speech automatically.")
        end
    end
    if f.voiceTitle then f.voiceTitle:SetText(locked and"VOICE BASELINE • CALIBRATED"or"VOICE STARTING POINT")end
    if f.profanityTitle then f.profanityTitle:SetText(locked and"PROFANITY BASELINE • CALIBRATED"or"PROFANITY STARTING POINT")end
    if f.voiceHelp then
        local eff,learned=RPAL.GetPersonaEffectiveVoice();local suffix=""
        if d.voiceLearning.enabled then
            local ep,pl=RPAL.GetPersonaEffectiveProfanity and RPAL.GetPersonaEffectiveProfanity() or d.profanity,nil
            suffix="  Adaptive voice: "..string.upper(tostring(eff or d.voice)).." ("..tostring(learned and learned.lines or 0).." IC lines learned). Effective profanity: "..string.upper(tostring(ep or d.profanity)).."."
        end
        local rule=locked and"  This calibration baseline is read-only; only this character's later IC speech can move the effective result."or"  Choose the closest starting point. It becomes read-only when calibration finishes."
        f.voiceHelp:SetText(VOICES[d.voice].description..rule..suffix)
    end
end
local function buildConfig()
    if configFrame then return configFrame end
    local f=CreateFrame("Frame","ODYN_PersonaLivingConfig",UIParent,"BackdropTemplate");configFrame=f;f:SetSize(820,650);f:SetPoint("CENTER");f:SetFrameStrata("FULLSCREEN_DIALOG");f:SetFrameLevel(9700);f:SetToplevel(true);f:SetClampedToScreen(true);f:EnableMouse(true);backdrop(f,C.bg,C.gold);f:Hide()
    local title=text(f,"PERSONA BETA • LIVING HABITS",20,C.gold);title:SetPoint("TOPLEFT",22,-20)
    local sub=text(f,"Choose Voice and Profanity as calibration starting points. They become read-only when calibration finishes; ODYN then follows sustained changes in this character's own IC speech automatically.",11,C.muted);f.sub=sub;sub:SetPoint("TOPLEFT",22,-50);sub:SetWidth(770);sub:SetWordWrap(true)
    local close=button(f,"CLOSE",84,27);f.close=close;close:SetPoint("TOPRIGHT",-18,-16);close:SetScript("OnClick",function()f:Hide()end)
    local vtitle=text(f,"VOICE STARTING POINT",11.5,C.gold);f.voiceTitle=vtitle;vtitle:SetPoint("TOPLEFT",24,-93);f.voiceButtons={}
    for i,key in ipairs(VOICE_ORDER)do
        local b=button(f,VOICES[key].label:upper(),178,32);b.value=key;b:SetPoint("TOPLEFT",24+(i-1)*190,-112);b:SetScript("OnClick",function(self)RPAL.SetPersonaVoiceProfile(self.value);refreshConfig()end);f.voiceButtons[#f.voiceButtons+1]=b
    end
    f.voiceHelp=text(f,"",10.2,C.muted);f.voiceHelp:SetPoint("TOPLEFT",24,-149);f.voiceHelp:SetWidth(760);f.voiceHelp:SetWordWrap(true)
    f.adaptive=button(f,"ADAPTIVE: AUTOMATIC",184,29);f.adaptive:SetPoint("TOPLEFT",24,-184);f.adaptive._locked=true;f.adaptive:Disable()
    local ah=text(f,"Learns only from this character's IC /say and /yell. Recent repeated spelling, dropped-g forms such as goin', slang, sentence rhythm and swear cadence gradually shape the effective inner voice.",9.6,C.muted);ah:SetPoint("LEFT",f.adaptive,"RIGHT",12,0);ah:SetWidth(560);ah:SetWordWrap(true)
    local ptitle=text(f,"PROFANITY STARTING POINT",11.5,C.gold);f.profanityTitle=ptitle;ptitle:SetPoint("TOPLEFT",24,-226);f.profanityButtons={}
    for i,key in ipairs(PROFANITY_ORDER)do local b=button(f,PROFANITY[key].label:upper(),140,32);b.value=key;b:SetPoint("TOPLEFT",24+(i-1)*151,-244);b:SetScript("OnClick",function(self)RPAL.SetPersonaProfanityLevel(self.value);refreshConfig()end);f.profanityButtons[#f.profanityButtons+1]=b end
    local ititle=text(f,"IDLE IMPULSES",11.5,C.gold);ititle:SetPoint("TOPLEFT",24,-294)
    local ihint=text(f,"Only selected habits can surface. While IC, one impulse is rolled every 5–15 minutes; ODYN avoids repeating the same habit twice in a row.",10,C.muted);ihint:SetPoint("TOPLEFT",24,-311);ihint:SetWidth(760)
    local scroll=CreateFrame("ScrollFrame",nil,f,"UIPanelScrollFrameTemplate");scroll:SetPoint("TOPLEFT",22,-335);scroll:SetPoint("BOTTOMRIGHT",-31,70);if RPAL.SkinScrollFrame then RPAL.SkinScrollFrame(scroll)end
    local child=CreateFrame("Frame",nil,scroll);child:SetWidth(744);scroll:SetScrollChild(child);f.idleButtons={}
    local cols=3;local bw,bh=236,42;local gx,gy=10,8
    for i,key in ipairs(IDLE_ORDER)do local def=IDLES[key];local idx=i-1;local col=idx%cols;local row=math.floor(idx/cols);local b=button(child,"",bw,bh);b.key=key;b:SetPoint("TOPLEFT",col*(bw+gx),-row*(bh+gy));b.label:SetText(def.label);b.label:ClearAllPoints();b.label:SetPoint("LEFT",10,6);b.label:SetWidth(175);b.label:SetJustifyH("LEFT");b.state=text(b,"OFF",9,C.muted);b.state:SetPoint("BOTTOMLEFT",10,5);b:SetScript("OnClick",function(self)local d=ensureLiving();RPAL.SetPersonaIdleActivity(self.key,not d.idle.selected[self.key]);refreshConfig()end);f.idleButtons[#f.idleButtons+1]=b end
    child:SetHeight(math.ceil(#IDLE_ORDER/cols)*(bh+gy)+6)
    f.currently=button(f,"TRP CURRENTLY: OFF",190,30);f.currently:SetPoint("BOTTOMLEFT",22,22);f.currently:SetScript("OnClick",function()local d=ensureLiving();RPAL.SetPersonaIdleCurrentlyEnabled(not d.idle.writeCurrently);refreshConfig()end)
    local ch=text(f,"When enabled, ODYN touches only line 1. Existing 'Currently:' on line 1 is replaced; otherwise a new line is inserted above everything else. No full-field cache or restore is used.",9.6,C.muted);f.currentlyHelp=ch;ch:SetPoint("BOTTOMLEFT",225,19);ch:SetWidth(565);ch:SetWordWrap(true)
    f.finish=button(f,"FINISH CALIBRATION",170,30);f.finish:SetPoint("BOTTOMRIGHT",-22,20);f.finish:Hide()
    f.finish:SetScript("OnClick",function(self)
        local cb=f._calibrationCallback
        f._calibrationCallback=nil;f._calibrationMode=nil;f:Hide()
        if cb then pcall(cb) end
    end)
    f:SetScript("OnShow",refreshConfig)
    if UISpecialFrames then local found=false;for _,n in ipairs(UISpecialFrames)do if n=="ODYN_PersonaLivingConfig"then found=true break end end;if not found then table.insert(UISpecialFrames,"ODYN_PersonaLivingConfig")end end
    return f
end
function RPAL.OpenPersonaLivingConfig(calibrationMode,onComplete)
    local f=buildConfig()
    if not calibrationMode and RPAL.IsPersonalityCalibrated and not RPAL.IsPersonalityCalibrated() then return false end
    f._calibrationMode=calibrationMode and true or nil;f._calibrationCallback=calibrationMode and onComplete or nil
    f.close.label:SetText(calibrationMode and "CANCEL" or "CLOSE")
    f.finish:SetShown(calibrationMode and true or false)
    if f.currentlyHelp then f.currentlyHelp:SetWidth(calibrationMode and 380 or 565) end
    f:Show();f:Raise();refreshConfig();return true
end
function RPAL.ClosePersonaLivingConfig(silent)
    local f=configFrame;if not f then return false end
    f._calibrationCallback=nil;f._calibrationMode=nil;f:Hide();return true
end
