local ADDON_NAME, RPAL = ...

-- ============================================================
-- ODYN Persona Learning
-- A character-scoped behavioural model learned from the player's own /say and
-- /emote roleplay. It never reads party/guild/whisper text for learning.
-- Parenthetical lines such as "(brb)" are treated as OOC and discarded.
--
-- Design goals:
--   * calibration is the player's starting point, not a permanent cage;
--   * one dramatic line must never rewrite a character;
--   * repeated behaviour increases confidence and slowly moves the persona;
--   * topic likes/dislikes are learned separately from broad traits;
--   * changes are explainable: evidence and recent observations are retained;
--   * online reactions share only a tiny reaction result, never the persona DB.
-- ============================================================

local LEARNING_VERSION = 7
local CALIBRATION_VERSION = 2
local REACTION_PREFIX = "ODYNPLR1"
local MAX_CHANGE_LOG = 40
local MAX_EVIDENCE_LOG = 80

local function clamp(v, lo, hi)
    v = tonumber(v) or lo
    if v < lo then return lo end
    if v > hi then return hi end
    return v
end

local function trim(s)
    return tostring(s or ""):gsub("^%s+", ""):gsub("%s+$", "")
end

local function lowerClean(s)
    s = tostring(s or "")
    s = s:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", "")
    s = s:gsub("|H.-|h(.-)|h", "%1")
    s = s:gsub("[\r\n]+", " ")
    -- Normalize punctuation people commonly paste from Discord/TRP so the
    -- semantic rules do not depend on straight ASCII apostrophes.
    s = s:gsub("’", "'"):gsub("‘", "'"):gsub("“", '"'):gsub("”", '"')
    s = s:gsub("—", " - "):gsub("–", " - ")
    s = s:gsub("%s+", " ")
    return trim(s):lower()
end

-- Chat language labels are transport metadata, not roleplay content. In
-- particular, "[Orcish] ..." must never become evidence that the character
-- likes Orcs. The raw line is still retained by cognition for context.
local PERSONA_LANGUAGE_MARKERS={
    common=true,orcish=true,darnassian=true,dwarven=true,gnomish=true,draenei=true,
    thalassian=true,gutterspeak=true,taurahe=true,troll=true,goblin=true,pandaren=true,
    shalassian=true,vulpera=true,draconic=true,demonic=true,titan=true,kalimag=true,
    eredun=true,nerubian=true,furbolg=true,binary=true,shathyar=true,sprite=true,
}
local function stripLanguageMarkers(value)
    local text=tostring(value or"")
    text=text:gsub("%[([^%]]+)%]",function(label)
        local key=trim(label):lower():gsub("^language%s*:%s*",""):gsub("%s+"," ")
        if PERSONA_LANGUAGE_MARKERS[key]then return" "end
        return"["..label.."]"
    end)
    return trim(text:gsub("%s+"," "))
end
RPAL.StripPersonaLanguageMarkers=stripLanguageMarkers

local function pushLimited(t, row, limit)
    t[#t+1] = row
    while #t > (limit or 40) do table.remove(t, 1) end
end

local function playerChatName()
    local n, r = UnitFullName and UnitFullName("player")
    n = n or (UnitName and UnitName("player")) or ""
    r = r or (GetRealmName and GetRealmName()) or ""
    r = tostring(r):gsub("[%s%-']", "")
    if n ~= "" and r ~= "" then return (n.."-"..r):lower() end
    return tostring(n):lower()
end

local function normalizeAuthor(name)
    name = tostring(name or ""):gsub("%s", "")
    if name == "" then return "" end
    if not name:find("-", 1, true) then
        local _, r = UnitFullName and UnitFullName("player")
        r = r or (GetRealmName and GetRealmName()) or ""
        r = tostring(r):gsub("[%s%-']", "")
        if r ~= "" then name = name.."-"..r end
    end
    return name:lower()
end

local function isOOCParenthetical(text)
    local s = trim(text)
    if s == "" then return true end
    -- Fully parenthetical messages are OOC. Nested ((...)) is covered too.
    local function balancedWhole(candidate)
        if candidate:sub(1,1)~="(" or candidate:sub(-1)~=")" then return false end
        local depth=0
        for i=1,#candidate do
            local c=candidate:sub(i,i)
            if c=="(" then depth=depth+1 elseif c==")" then depth=depth-1 end
            if depth==0 and i<#candidate then return false end
            if depth<0 then return false end
        end
        return depth==0
    end
    if balancedWhole(s) then return true end
    -- Some clients/event paths may prefix custom /emote text with a character
    -- name. If everything after that first token is parenthetical, it is still OOC.
    local remainder=s:match("^%S+%s+(.+)$")
    if remainder and balancedWhole(trim(remainder)) then return true end
    return false
end

-- Strong contamination guard. Persona learns only from in-character material;
-- addon chatter and obvious OOC shorthand are filtered,
-- never character evidence. This is intentionally conservative: one technical
-- token is not enough unless the line is an explicit command / known OOC phrase.
local OOC_EXACT={
    ["ooc"]=true,["im ooc"]=true,["i'm ooc"]=true,["i am ooc"]=true,["brb"]=true,["afk"]=true,
    ["lol"]=true,["lmao"]=true,["lmfao"]=true,["xd"]=true,["xdd"]=true,["ngl"]=true,
}
local OOC_PHRASES={
    "check discord","on discord","discord link","curseforge","wow api","api does not","addon version",
    "working on my addon","workin' on my addon","the addon","this addon","addon bug","ui bug","reload ui",
    "reloading one sec","savedvariables",
    "send you the zip","send the zip","newest version","unlisted project","theme color","layout & navigation",
}
local function inputClassification(text)
    local raw=trim(text);local low=lowerClean(raw)
    if raw=="" then return {ignored=true,reason="empty"} end
    if isOOCParenthetical(raw) then return {ignored=true,reason="parenthetical_ooc"} end
    if OOC_EXACT[low] then return {ignored=true,reason="ooc_shorthand"} end
    local padded=" "..low:gsub("[^%w']"," "):gsub("%s+"," ").." "
    for _,token in ipairs({" addon "," discord "," curseforge "," savedvariables "," github "}) do if padded:find(token,1,true) then return {ignored=true,reason="technical_ooc"} end end
    for _,token in ipairs({" lol "," lmao "," lmfao "," xd "," xdd "," ngl "," wtf "}) do if padded:find(token,1,true) then return {ignored=true,reason="ooc_shorthand"} end end
    if low:sub(-1)=="*" then local wc=0;for _ in low:gmatch("%S+")do wc=wc+1 end;if wc<=6 then return {ignored=true,reason="correction_ooc"} end end
    if low:match("^/run%f[%A]") or low:match("^/script%f[%A]") or low:match("^/dump%f[%A]") or low:match("^/reload%f[%A]") or low:match("^/console%f[%A]") then
        return {ignored=true,reason="technical_command"}
    end
    for _,phrase in ipairs(OOC_PHRASES) do if low:find(phrase,1,true) then return {ignored=true,reason="technical_ooc"} end end
    local tech=0
    for _,word in ipairs({" addon "," discord "," curseforge "," api "," ui "," debug "," bug "," reload "," version "," zip "}) do
        if (" "..low.." "):find(word,1,true) then tech=tech+1 end
    end
    if tech>=2 then return {ignored=true,reason="technical_ooc"} end
    return {ignored=false,reason="ic_candidate"}
end
RPAL.GetPersonaInputClassification=inputClassification
RPAL.IsPersonalityLearningOOCMessage=function(text)local r=inputClassification(text);return r and r.ignored or false end

local function characterIsIC()
    if not RPAL.IsTRPInCharacter then return true end
    local ok,res=pcall(RPAL.IsTRPInCharacter);return ok and res and true or false
end
local function logIgnored(l,text,reason,eventName)
    if type(l)~="table" then return end
    l.diagnostics=type(l.diagnostics)=="table" and l.diagnostics or {}
    local d=l.diagnostics;d.ignoredCounts=type(d.ignoredCounts)=="table" and d.ignoredCounts or {}
    reason=tostring(reason or "ignored");d.ignoredCounts[reason]=(tonumber(d.ignoredCounts[reason]) or 0)+1
end

local TRAIT_CUES = {
    chaoticLawful = {
        left={"screw the rules","break the rules","ignore the rules","rules are made to be broken","do it anyway","who cares about the rules","chaos","reckless","improvise","make it up as we go","no rules"},
        right={"follow the rules","by the book","the law","lawful","procedure","protocol","proper procedure","regulation","orders are orders","keep order","discipline","oath","contract","duty"},
    },
    forgivingVindictive = {
        left={"i forgive","forgive them","let it go","leave it be","second chance","no hard feelings","mercy","spare them","not worth holding onto","move on"},
        right={"make them pay","payback","revenge","vengeance","i won't forget","i will not forget","never forgive","hold a grudge","settle the score","get even","they'll regret","they will regret"},
    },
    altruisticSelfish = {
        left={"let me help","i can help","i'll help","i will help","are you alright","are you okay","take mine","you can have mine","i'll handle it","i will handle it","i'll protect","i will protect","stay behind me","i've got you","i have got you","for you","share it"},
        right={"not my problem","what's in it for me","what is in it for me","pay me","i don't care","i do not care","your problem","keep your hands off my","i come first","why should i help","nothing for free"},
    },
    truthfulDeceitful = {
        left={"to be honest","truth is","tell the truth","i admit","i'll admit","i will admit","honestly","i swear","no point lying","i won't lie","i will not lie","the truth"},
        right={"lie to them","i lied","i'm lying","i am lying","bluff","trick them","deceive","pretend","fool them","mislead","manipulate","keep them guessing","don't tell them","do not tell them"},
    },
    gentleBrutal = {
        left={"easy now","gently","careful with them","please","thank you","i'm sorry","i am sorry","calm down","it's alright","it is alright","are you hurt","take it easy","no need to hurt"},
        right={"break your","break their","kill you","kill them","cut you","cut them","crush","bleed","hurt you","hurt them","rip your","rip their","burn you","burn them","beat you","beat them","slaughter","torture"},
    },
    superstitiousRational = {
        left={"bad omen","good omen","an omen","fate","destiny","bad luck","good luck","lucky charm","curse","cursed","the spirits","sign from","meant to happen","stars say"},
        right={"evidence","proof","facts","logical","reason says","reasonable explanation","coincidence","explain it","test it","verify","measure","we know because","there must be an explanation"},
    },
    renegadeParagon = {
        left={"don't tell me what to do","do not tell me what to do","my way","ignore the order","ignore orders","screw authority","damn the orders","i answer to no one","nobody commands me","renegade","rebel"},
        right={"my duty","our duty","responsibility","i gave my word","i made a promise","honor demands","honour demands","set an example","do the right thing","hold the line","standards","we have a duty"},
    },
    cautiousImpulsive = {
        left={"hold on","be careful","check first","think this through","make a plan","plan first","too risky","not worth the risk","let's make sure","lets make sure","slow down","watch your step"},
        right={"just do it","screw it","why not","right now","let's go","lets go","go go go","no time to think","we'll figure it out","we will figure it out","jump in","rush in","do it now"},
    },
    asceticBonVivant = {
        left={"i don't need much","i do not need much","keep it simple","simple is enough","i have enough","don't need luxury","do not need luxury","unnecessary","modest","travel light","need less"},
        right={"another drink","fine wine","feast","luxury","indulge","treat ourselves","treat myself","good food","good ale","celebrate","live a little","enjoy ourselves","the finer things"},
    },
    valorousSpineless = {
        left={"stand your ground","hold the line","i'm not running","i am not running","face them","i'll fight","i will fight","not afraid","stay behind me","i'll protect","i will protect","we stand"},
        right={"run away","let's get out of here","lets get out of here","hide","surrender","not worth dying","i'm scared","i am scared","we should flee","get me out","i can't face","i cannot face"},
    },
    introvertedExtroverted = {
        left={"leave me alone","need some quiet","somewhere quiet","too many people","need to be alone","i prefer being alone","keep to myself","don't feel like talking","do not feel like talking","enough people","not much of a people person","i'm not a people person","i am not a people person","rather keep to myself","prefer my own company","i need space","crowds wear me out","people wear me out","socializing is exhausting","socialising is exhausting","i'd rather listen","i would rather listen"},
        right={"let's talk","lets talk","come join us","the more the merrier","let's celebrate","lets celebrate","come with me","good to see you","meet everyone","join me","join us","i like meeting people","love meeting people","i enjoy company","i like company","let's meet them","lets meet them","come say hello","i'll introduce myself","i will introduce myself","i like a crowd","crowds energize me","crowds energise me"},
    },
}

local DIALOGUE_ACTS = {
    {patterns={"thank you","thanks","appreciate it","much appreciated"}, traits={gentleBrutal=-.45,altruisticSelfish=-.18,introvertedExtroverted=.08}, moral=.16, label="gratitude"},
    {patterns={"i'm sorry","i am sorry","my apologies","forgive me"}, traits={gentleBrutal=-.40,truthfulDeceitful=-.12,forgivingVindictive=-.12}, moral=.10, label="apology"},
    {patterns={"are you alright","are you okay","need a hand","let me help","i can help","stay behind me"}, traits={altruisticSelfish=-.62,gentleBrutal=-.25,valorousSpineless=-.28}, moral=.32, label="offer_help"},
    {patterns={"spare them","let them go","give them a chance","second chance","show mercy"}, traits={forgivingVindictive=-.72,gentleBrutal=-.34}, moral=.35, label="mercy"},
    {patterns={"make them pay","i'll make you pay","i will make you pay","you'll regret","you will regret","settle the score"}, traits={forgivingVindictive=.72,gentleBrutal=.30}, moral=-.28, label="retaliation"},
    {patterns={"i'll kill","i will kill","break your","rip your","cut your throat","burn you alive"}, traits={gentleBrutal=.86,forgivingVindictive=.42,valorousSpineless=-.12}, moral=-.48, label="threat"},
    {patterns={"not my problem","what's in it for me","what is in it for me","pay me first"}, traits={altruisticSelfish=.76,gentleBrutal=.10}, moral=-.16, label="self_interest"},
    {patterns={"i promise","i give you my word","i gave my word","you have my word"}, traits={truthfulDeceitful=-.36,renegadeParagon=.28,chaoticLawful=.20}, moral=.10, label="promise"},
    {patterns={"don't tell them","do not tell them","keep this between us","they don't need to know","they do not need to know"}, traits={truthfulDeceitful=.42,introvertedExtroverted=-.12}, moral=0, label="concealment"},
    {patterns={"according to the law","follow procedure","proper procedure","orders are orders"}, traits={chaoticLawful=.62,renegadeParagon=.46}, moral=0, label="procedure"},
    {patterns={"screw the rules","damn the rules","ignore the order","ignore orders"}, traits={chaoticLawful=-.64,renegadeParagon=-.48,cautiousImpulsive=.18}, moral=0, label="defiance"},
    {patterns={"take your time","no rush","we can wait","let's think first","lets think first"}, traits={gentleBrutal=-.22,cautiousImpulsive=-.36}, moral=.05, label="patience"},
    {patterns={"we don't have to fight","we do not have to fight","put the weapon down","no need for violence","let's not fight","lets not fight"}, traits={gentleBrutal=-.48,cautiousImpulsive=-.18,forgivingVindictive=-.14}, moral=.18, label="restraint"},
    {patterns={"have a seat","help yourself","have some","you must be hungry","let me get you a drink","let me buy you a drink"}, traits={altruisticSelfish=-.34,gentleBrutal=-.20,introvertedExtroverted=.14,asceticBonVivant=.10}, moral=.10, label="hospitality"},
    {patterns={"i was wrong","i'm wrong","i am wrong","my fault","that was my fault","i made a mistake"}, traits={truthfulDeceitful=-.28,gentleBrutal=-.12,renegadeParagon=.08}, moral=.08, label="admission"},
    {patterns={"do as i say","you will obey","because i said so","you answer to me","you'll do what i tell you","you will do what i tell you"}, traits={gentleBrutal=.26,altruisticSelfish=.18,renegadeParagon=.18}, moral=-.05, label="dominance"},
    {patterns={"come on then","try me","is that all","bring it on","i dare you"}, traits={valorousSpineless=-.32,cautiousImpulsive=.30,gentleBrutal=.16}, moral=0, label="challenge"},
    {patterns={"i don't want trouble","i do not want trouble","i'd rather avoid a fight","i would rather avoid a fight","let's avoid trouble","lets avoid trouble"}, traits={cautiousImpulsive=-.30,gentleBrutal=-.20,valorousSpineless=.08}, moral=.04, label="deescalation"},
    {patterns={"you can trust me","i won't leave you","i will not leave you","i'm here for you","i am here for you","i've got your back","i have got your back"}, traits={altruisticSelfish=-.34,truthfulDeceitful=-.16,gentleBrutal=-.16}, moral=.14, label="reassurance"},
    {patterns={"i understand","i get it","that makes sense","i can see why","i don't blame you","i do not blame you"}, traits={gentleBrutal=-.16,forgivingVindictive=-.18,altruisticSelfish=-.08}, moral=.06, label="empathy"},
    {patterns={"you earned it","you deserve it","fair is fair","that's only fair","that is only fair"}, traits={chaoticLawful=.10,renegadeParagon=.12,altruisticSelfish=-.08}, moral=.08, label="fairness"},
    {patterns={"i don't owe you","i do not owe you","you owe me","remember what you owe","debt is debt"}, traits={altruisticSelfish=.16,forgivingVindictive=.12,chaoticLawful=.08}, moral=-.03, label="transactional"},
    {patterns={"keep your voice down","not here","someone might hear","we're being watched","we are being watched","watch what you say"}, traits={cautiousImpulsive=-.20,introvertedExtroverted=-.08}, moral=0, label="social caution"},
    {patterns={"i'll take responsibility","i will take responsibility","this is on me","i'll answer for it","i will answer for it"}, traits={renegadeParagon=.26,truthfulDeceitful=-.12,altruisticSelfish=-.10}, moral=.08, label="accountability"},
    {patterns={"i don't believe you","i do not believe you","prove it","show me proof","where's the proof","where is the proof"}, traits={superstitiousRational=.24,cautiousImpulsive=-.08}, moral=0, label="skepticism"},
    {patterns={"i knew it","called it","bad feeling about this","something feels wrong","trust my gut","gut feeling"}, traits={superstitiousRational=-.18,cautiousImpulsive=-.08}, moral=0, label="intuition"},
}

local EMOTE_ACTS = {
    {patterns={" smiles "," smiles.","smiles at","offers a hand","offers their hand","offers his hand","offers her hand","nods reassuringly","pats ","comforts ","gives a reassuring smile","softens their expression","softens his expression","softens her expression"}, traits={gentleBrutal=-.30,altruisticSelfish=-.15}, moral=.08, label="warm emote"},
    {patterns={" glares ","glares at","snarls at","spits at","shoves ","strikes ","punches ","kicks ","grabs ","threatens ","bares their teeth","bares his teeth","bares her teeth","cracks their knuckles","cracks his knuckles","cracks her knuckles"}, traits={gentleBrutal=.42,forgivingVindictive=.12}, moral=-.08, label="hostile emote"},
    {patterns={" steps between ","moves between","shields ","protects ","stands in front of","moves in front of","covers ","guards ","pulls behind"}, traits={altruisticSelfish=-.35,valorousSpineless=-.38}, moral=.16, label="protective emote"},
    {patterns={" backs away","steps back","retreats","hides behind","keeps their distance","keeps his distance","keeps her distance","edges away","moves away from"}, traits={valorousSpineless=.28,cautiousImpulsive=-.18}, moral=0, label="withdrawal emote"},
    {patterns={"hesitates","pauses before","checks the room","looks around carefully","scans the room","studies the area","watches carefully","keeps an eye on","checks their surroundings","checks his surroundings","checks her surroundings","tests the ground","examines before"}, traits={cautiousImpulsive=-.34,superstitiousRational=.08}, moral=0, label="cautious emote"},
    {patterns={"lunges","charges in","rushes in","leaps in","without hesitation","immediately reaches","snatches ","blurts ","dives in"}, traits={cautiousImpulsive=.34,valorousSpineless=-.10}, moral=0, label="impulsive emote"},
    {patterns={"offers food","offers a drink","shares ","hands over","gives to","helps ","tends to","bandages ","heals ","supports their weight","helps them up","helps him up","helps her up"}, traits={altruisticSelfish=-.34,gentleBrutal=-.15}, moral=.12, label="helpful emote"},
    {patterns={"pockets ","keeps for","pulls away from","refuses to share","takes the last","claims for"}, traits={altruisticSelfish=.28}, moral=-.04, label="self-serving emote"},
    {patterns={"waves at","greets ","approaches ","walks over to","joins ","introduces ","offers a greeting","starts chatting","strikes up"}, traits={introvertedExtroverted=.24,gentleBrutal=-.06}, moral=0, label="social approach"},
    {patterns={"avoids eye contact","looks away from","withdraws from","moves to a quieter","keeps to themselves","keeps to himself","keeps to herself","hangs back","stays at the edge","finds a quiet corner"}, traits={introvertedExtroverted=-.24,cautiousImpulsive=-.05}, moral=0, label="social withdrawal"},
    {patterns={"stands their ground","stands his ground","stands her ground","steps forward","faces directly","draws their weapon","draws his weapon","draws her weapon","squares their shoulders","squares his shoulders","squares her shoulders"}, traits={valorousSpineless=-.30,cautiousImpulsive=.05}, moral=0, label="brave posture"},
    {patterns={"trembles","shakes nervously","cowers","flinches back","freezes in fear","looks for an exit","glances toward the exit","ducks behind"}, traits={valorousSpineless=.30,cautiousImpulsive=-.08}, moral=0, label="fear response"},
    {patterns={"bows respectfully","salutes ","stands at attention","waits for permission","follows the order","obeys the order"}, traits={chaoticLawful=.22,renegadeParagon=.22}, moral=0, label="respectful procedure"},
    {patterns={"rolls their eyes at the order","rolls his eyes at the order","rolls her eyes at the order","ignores the order","disobeys ","breaks formation","walks past the guard","dismisses the command"}, traits={chaoticLawful=-.22,renegadeParagon=-.28}, moral=0, label="defiant emote"},
    {patterns={"laughs with","chuckles with","grins at","embraces ","hugs ","squeezes their shoulder","leans closer to","sits beside"}, traits={introvertedExtroverted=.10,gentleBrutal=-.13}, moral=.04, label="affectionate emote"},
    {patterns={"scoffs at","sneers at","grimaces at","recoils from","turns their nose up","turns his nose up","turns her nose up","looks disgusted","looks repulsed"}, traits={gentleBrutal=.08,forgivingVindictive=.06}, moral=0, label="disapproving emote"},
}

local TOPICS = {
    magic={display="Magic", words={"magic","sorcery","spell","spells","spellwork","arcane","arcane magic"}, category="arcane"},
    nature={display="Nature", words={"nature","forest","forests","woods","wilds","trees","plants","garden"}, category="nature"},
    animals={display="Animals", words={"animal","animals","beast","beasts","pet","pets","horse","horses","wolf","wolves","cats","dogs"}, category="nature"},
    money={display="Coin", words={"coin","coins","money","gold","wealth","profit","payment","paid","fortune"}, category="money"},
    food={display="Food", words={"food","meal","meals","eat","eating","bread","meat","fruit","cooking","cook","snack"}, category="food"},
    drink={display="Drinks", words={"drink","drinks","ale","beer","wine","whiskey","whisky","tea","coffee"}, category="drink"},
    home={display="Home", words={"home","house","farm","cottage","homestead","settle down","room"}, category="home"},
    duty={display="Duty", words={"duty","oath","responsibility","obligation","service","serve","promise"}, category="duty"},
    travel={display="Travel", words={"travel","journey","road","roads","wander","voyage","caravan","trip"}, category="travel"},
    adventure={display="Adventure", words={"adventure","adventures","explore","exploring","expedition","quest","quests"}, category="travel"},
    danger={display="Danger", words={"danger","risk","risky","unsafe","threat","survival"}, category="danger"},
    violence={display="Violence", words={"violence","fight","fighting","combat","bloodshed","killing","kill","battle","war"}, category="danger"},
    family={display="Family", words={"family","mother","father","parents","brother","sister","siblings","kin"}, category="family"},
    work={display="Work", words={"work","job","jobs","business","profession","labor","labour"}, category="work"},
    knowledge={display="Knowledge", words={"knowledge","learn","learning","study","research","history","understanding","education"}, category="knowledge"},
    books={display="Books", words={"book","books","library","libraries","reading","read"}, category="knowledge"},
    freedom={display="Freedom", words={"freedom","liberty","independence","independent","free choice"}, category="freedom"},
    justice={display="Justice", words={"justice","fairness","fair","innocent","guilty","law","court"}, category="justice"},
    revenge={display="Revenge", words={"revenge","vengeance","payback","grudge","retaliation"}, category="revenge"},
    faith={display="Faith", words={"faith","prayer","pray","religion","sacred","temple","church","gods","god"}, category="faith"},
    light={display="The Light", words={"the light","holy light","light worship","light's blessing","lights blessing"}, category="faith"},
    power={display="Power", words={"power","influence","control","command","authority","rank","status"}, category="power"},
    authority={display="Authority", words={"authority","government","the crown","official authority","command structure","chain of command"}, category="power"},
    nobility={display="Nobility", words={"noble","nobles","nobility","lord","lords","lady","ladies","aristocrat","aristocrats"}, category="power"},
    craft={display="Crafting", words={"craft","crafting","smith","forge","engineer","engineering","tailor","tools","repair","build"}, category="craft"},
    technology={display="Technology", words={"technology","machine","machines","mechanical","engine","engines","gadget","gadgets","gnomish","goblin engineering"}, category="craft"},
    medicine={display="Medicine", words={"medicine","medical care","healing practice","bandage","bandages","remedy","remedies","treatment","treatments","surgery"}, category="medicine"},
    sea={display="The Sea", words={"sea","ocean","sailing","harbor","harbour","dock","coast","coastline"}, category="sea"},
    music={display="Music", words={"music","song","songs","sing","singing","lute","violin","drum","melody","tune"}, category="music"},
    crowds={display="Crowds", words={"crowd","crowds","busy place","many people","everyone here","packed tavern"}, category="social"},
    romance={display="Romance", words={"romance","romantic","lover","lovers","date","dating","kiss","kissing","courtship"}, category="social"},
    undead={display="Undead", words={"undead","forsaken","death knight","death knights","ghoul","ghouls","necromancy","necromancer"}, category="guarded"},
    demons={display="Demons", words={"demon","demons","demonic","eredar","man'ari","manari"}, category="guarded"},
    fel={display="Fel", words={"fel","fel magic","fel energy","fel corruption"}, category="guarded"},
    void={display="Void", words={"void","void magic","shadow magic","old gods","old god"}, category="guarded"},
    dragons={display="Dragons", words={"dragon","dragons","drake","drakes","dragonflight"}, category="curious"},
    elementals={display="Elementals", words={"elemental","elementals","fire elemental","water elemental","earth elemental","air elemental"}, category="superstitious"},
    spirits={display="Spirits", words={"spirit","spirits","ancestor","ancestors","ghost","ghosts"}, category="superstitious"},
    shadow={display="Shadow", words={"shadow","shadows","shadow priest","shadow magic"}, category="guarded"},
    necromancy={display="Necromancy", words={"necromancy","necromantic","necromancer","necromancers"}, category="guarded"},
    taverns={display="Taverns", words={"tavern","taverns","inn","inns","pub","bar"}, category="comfort"},
    festivals={display="Festivals", words={"festival","festivals","celebration","celebrations","party","parties","feast day"}, category="social"},
    silence={display="Silence", words={"silence","quiet","stillness","peace and quiet"}, category="reflective"},
    cities={display="Cities", words={"city","cities","urban places","big city","big cities"}, category="social"},
    wilderness={display="Wilderness", words={"wilderness","wilds","countryside","backcountry","open land"}, category="nature"},
    rain={display="Rain", words={"rain","raining","rainy","downpour"}, category="reflective"},
    snow={display="Snow", words={"snow","snowing","snowy","blizzard"}, category="nature"},
    fire={display="Fire", words={"fire","flame","flames","campfire","hearthfire"}, category="comfort"},
    night={display="Night", words={"night","nighttime","moonlight","late night"}, category="reflective"},
    dueling={display="Dueling", words={"duel","duels","dueling","spar","sparring"}, category="danger"},
    weapons={display="Weapons", words={"weapon","weapons","sword","swords","axe","axes","dagger","daggers","bow","bows","gun","guns"}, category="danger"},
    armor={display="Armor", words={"armor","armour","plate","mail","leather armor","shield","shields"}, category="practical"},
    guards={display="Guards", words={"guard","guards","watchman","watchmen","city watch"}, category="dutiful"},
    military={display="Military", words={"military","army","soldier","soldiers","regiment","troops"}, category="dutiful"},
    merchants={display="Merchants", words={"merchant","merchants","trader","traders","vendor","vendors","shopkeeper"}, category="practical"},
    scholars={display="Scholars", words={"scholar","scholars","academic","academics","researcher","researchers"}, category="curious"},
    healers={display="Healers", words={"healer","healers","medic","medics","doctor","doctors"}, category="kind"},
    criminals={display="Criminals", words={"criminal","criminals","thief","thieves","bandit","bandits","smuggler","smugglers"}, category="rebellious"},
    pirates={display="Pirates", words={"pirate","pirates","corsair","corsairs","buccaneer"}, category="rebellious"},
    commonfolk={display="Common Folk", words={"common folk","commoner","commoners","ordinary people","working folk"}, category="social"},
    wealth={display="Luxury", words={"luxury","luxuries","expensive","fine clothes","jewelry","jewellery"}, category="comfort"},
    poverty={display="Hardship", words={"poverty","poor","hardship","hungry families","starving"}, category="kind"},
    elders={display="Elders", words={"elder","elders","old folk","elderly"}, category="kind"},
    strangers={display="Strangers", words={"stranger","strangers","new people","unknown people"}, category="social"},
    crowdsocial={display="Social Gatherings", words={"gathering","gatherings","social event","social events","crowded room"}, category="social"},
    solitude={display="Solitude", words={"solitude","alone time","being alone","my own company"}, category="reflective"},
    competition={display="Competition", words={"competition","competitive","contest","contests","tournament","tournaments"}, category="ambitious"},
    gambling={display="Gambling", words={"gambling","gamble","cards for coin","bet","betting","wager","wagers"}, category="chaotic"},
    art={display="Art", words={"art","painting","paintings","drawing","drawings","sculpture","sculptures"}, category="curious"},
    theater={display="Theatre", words={"theatre","theater","play","plays","actor","actors","performance"}, category="social"},
    dancing={display="Dancing", words={"dance","dancing","dances"}, category="social"},
    cooking={display="Cooking", words={"cooking","cook","cooks","recipe","recipes","kitchen"}, category="comfort"},
    fishing={display="Fishing", words={"fishing","fish","fisherman","fishermen"}, category="nature"},
    hunting={display="Hunting", words={"hunting","hunt","hunter","hunters","tracking prey"}, category="danger"},
    archaeology={display="Archaeology", words={"archaeology","archaeologist","archaeologists","ruins","relic hunting"}, category="curious"},
    artifacts={display="Artifacts", words={"artifact","artifacts","relic","relics","ancient object","ancient objects"}, category="curious"},
    portals={display="Portals", words={"portal","portals","teleport","teleportation"}, category="arcane"},
    flying={display="Flying", words={"flying","flight","gryphon","gryphons","wyvern","wyverns"}, category="travel"},
    horses={display="Riding", words={"horse","horses","riding","mount","mounts"}, category="travel"},
    ships={display="Ships", words={"ship","ships","boat","boats","vessel","vessels"}, category="sea"},
    storms={display="Storms", words={"storm","storms","thunder","lightning","tempest"}, category="guarded"},
    order={display="Order", words={"order","structure","discipline","routine","organization","organisation"}, category="dutiful"},
    chaos={display="Chaos", words={"chaos","unpredictable","mayhem","disorder"}, category="chaotic"},
    secrets={display="Secrets", words={"secret","secrets","hidden truth","confidential"}, category="guarded"},
    gossip={display="Gossip", words={"gossip","rumor","rumour","rumors","rumours"}, category="social"},
    tradition={display="Tradition", words={"tradition","traditions","custom","customs","old ways"}, category="dutiful"},
    change={display="Change", words={"change","changes","new ways","reform","reforms"}, category="restless"},
    leadership={display="Leadership", words={"leader","leaders","leadership","command","commander"}, category="ambitious"},
    teamwork={display="Teamwork", words={"teamwork","team","together","cooperate","cooperation"}, category="kind"},
    solitudework={display="Working Alone", words={"work alone","working alone","do it myself","by myself"}, category="practical"},
    risk={display="Risk", words={"risk","risks","gamble with","take a chance","dangerous choice"}, category="chaotic"},
    routine={display="Routine", words={"routine","routines","schedule","habit","habits"}, category="practical"},
    alcohol={display="Alcohol", words={"alcohol","booze","liquor","strong drink","hard drink","ale","beer","wine","whiskey","whisky","rum","gin","vodka","brandy"}, category="drink"},
    tea={display="Tea", words={"tea","teahouse","tea house"}, category="drink"},
    coffee={display="Coffee", words={"coffee","coffeehouse","coffee house"}, category="drink"},
    sweets={display="Sweets", words={"sweet","sweets","cake","cakes","pastry","pastries","dessert","desserts","chocolate","candy","candies"}, category="food"},
    meat={display="Meat", words={"meat","steak","steaks","sausage","sausages","roast","roasted meat"}, category="food"},
    vegetables={display="Vegetables", words={"vegetable","vegetables","greens","salad","salads"}, category="food"},
    spicyfood={display="Spicy Food", words={"spicy food","spicy","hot sauce","peppery"}, category="food"},
    smoking={display="Smoking", words={"smoke","smoking","pipe","pipes","cigar","cigars","cigarette","cigarettes","tobacco"}, category="comfort"},
    sleep={display="Sleep", words={"sleep","sleeping","nap","naps","rest","bed","bedtime"}, category="comfort"},
    warmth={display="Warmth", words={"warmth","warm","cozy","cosy","hearth","fireplace"}, category="comfort"},
    cold={display="Cold", words={"cold","freezing","chilly","frost","frosty"}, category="guarded"},
    heat={display="Heat", words={"heat","hot weather","scorching","sweltering"}, category="guarded"},
    friendship={display="Friendship", words={"friendship","friendships","friend","friends","companionship","companion"}, category="social"},
    loyalty={display="Loyalty", words={"loyalty","loyal","faithful","stand by","stick by"}, category="dutiful"},
    betrayal={display="Betrayal", words={"betrayal","betray","betrayed","traitor","traitors","backstab","backstabbing"}, category="guarded"},
    honesty={display="Honesty", words={"honesty","honest","truthfulness","truthful","telling the truth"}, category="dutiful"},
    lies={display="Lies", words={"lie","lies","lying","liar","liars","deception","deceit"}, category="guarded"},
    mercy={display="Mercy", words={"mercy","merciful","spare them","second chance","clemency"}, category="kind"},
    charity={display="Charity", words={"charity","donation","donations","give to the poor","help the poor","alms"}, category="kind"},
    honor={display="Honor", words={"honor","honour","honorable","honourable","code of honor","code of honour"}, category="dutiful"},
    fame={display="Fame", words={"fame","famous","celebrity","renown","recognition","recognition"}, category="ambitious"},
    reputation={display="Reputation", words={"reputation","repute","good name","public image"}, category="ambitious"},
    etiquette={display="Etiquette", words={"etiquette","manners","proper manners","polite society","decorum"}, category="dutiful"},
    diplomacy={display="Diplomacy", words={"diplomacy","diplomatic","negotiation","negotiations","negotiate","peace talks"}, category="kind"},
    politics={display="Politics", words={"politics","political","politician","politicians","council","parliament"}, category="power"},
    bureaucracy={display="Bureaucracy", words={"bureaucracy","bureaucratic","paperwork","permit","permits","forms"}, category="dutiful"},
    trade={display="Trade", words={"trade","trading","commerce","deal","deals","business deal","business deals"}, category="money"},
    poetry={display="Poetry", words={"poetry","poem","poems","poet","poets","verse"}, category="curious"},
    storytelling={display="Stories", words={"story","stories","storytelling","tale","tales","legend","legends"}, category="curious"},
    games={display="Games", words={"game","games","board game","board games","chess","dice game","dice games"}, category="social"},
    training={display="Training", words={"training","train","practice","drill","drills","exercise","workout"}, category="practical"},
    shopping={display="Shopping", words={"shopping","shop","shops","buying things","browse the market","browsing the market"}, category="money"},
    fashion={display="Fashion", words={"fashion","fashionable","clothes","clothing","outfit","outfits","dress","dresses"}, category="comfort"},
    jewelry={display="Jewelry", words={"jewelry","jewellery","necklace","necklaces","ring","rings","bracelet","bracelets","gems","gemstones"}, category="comfort"},
    mountains={display="Mountains", words={"mountain","mountains","peak","peaks","highlands"}, category="nature"},
    beaches={display="Beaches", words={"beach","beaches","shore","shores","seaside","coastline"}, category="nature"},
    deserts={display="Deserts", words={"desert","deserts","dunes","dune","sandstorm","sandstorms"}, category="guarded"},
    swamps={display="Swamps", words={"swamp","swamps","marsh","marshes","bog","bogs"}, category="guarded"},
    caves={display="Caves", words={"cave","caves","cavern","caverns","underground tunnels"}, category="guarded"},
    graveyards={display="Graveyards", words={"graveyard","graveyards","cemetery","cemeteries","grave","graves"}, category="guarded"},
    ruins={display="Ruins", words={"ruin","ruins","ancient ruins","old ruins"}, category="curious"},
    gardens={display="Gardens", words={"garden","gardens","flower garden","flower gardens","greenhouse","greenhouses"}, category="nature"},
    rooftops={display="Rooftops", words={"rooftop","rooftops","roof","roofs","from the roof","on the roof"}, category="guarded"},
    bloodmagic={display="Blood Magic", words={"blood magic","bloodmage","blood mage","blood ritual","blood rituals"}, category="guarded"},
    illusions={display="Illusions", words={"illusion","illusions","illusion magic","illusionist","illusionists"}, category="arcane"},
    enchantment={display="Enchanting", words={"enchanting","enchantment","enchantments","enchanted item","enchanted items"}, category="arcane"},
    alchemy={display="Alchemy", words={"alchemy","alchemist","alchemists","potion","potions","elixir","elixirs"}, category="craft"},
    titans={display="Titans", words={"titan","titans","titanic","titanforged","titan-forged"}, category="curious"},
    naaru={display="Naaru", words={"naaru"}, category="faith"},
    oldgods={display="Old Gods", words={"old god","old gods"}, category="guarded"},
    kirintor={display="Kirin Tor", words={"kirin tor"}, category="arcane"},
    argentcrusade={display="Argent Crusade", words={"argent crusade","argent dawn"}, category="dutiful"},
    cenarion={display="Cenarion Circle", words={"cenarion circle","cenarion"}, category="nature"},
    earthenring={display="Earthen Ring", words={"earthen ring"}, category="nature"},
    illidari={display="Illidari", words={"illidari"}, category="guarded"},
    shadopan={display="Shado-Pan", words={"shado-pan","shado pan"}, category="dutiful"},
    explorersleague={display="Explorers' League", words={"explorers' league","explorers league"}, category="curious"},
    si7={display="SI:7", words={"si:7","si7","stormwind intelligence"}, category="guarded"},
    bloodelves={display="Blood Elves", words={"blood elf","blood elves","sin'dorei","sindorei"}, category="social"},
    kultirans={display="Kul Tirans", words={"kul tiran","kul tirans","kul'tiran","kul'tirans"}, category="social"},
    darkiron={display="Dark Iron Dwarves", words={"dark iron","dark iron dwarf","dark iron dwarves"}, category="social"},
    lightforged={display="Lightforged", words={"lightforged","lightforged draenei"}, category="social"},
    mechagnomes={display="Mechagnomes", words={"mechagnome","mechagnomes"}, category="social"},
    dracthyr={display="Dracthyr", words={"dracthyr"}, category="social"},
    earthen={display="Earthen", words={"earthen"}, category="social"},
    peace={display="Peace", words={"peace","peaceful","ceasefire","truce","peacekeeping"}, category="kind"},
    war={display="War", words={"war","warfare","warfront","campaign","battlefield"}, category="danger"},

    -- People, professions and cultures. These intentionally overlap with broad
    -- concepts (for example, Mages also reinforce Magic) so the long-term model
    -- can retain both a broad attitude and a more precise social preference.
    mages={display="Mages", words={"mage","mages","wizard","wizards","sorcerer","sorcerers"}, category="arcane"},
    warlocks={display="Warlocks", words={"warlock","warlocks"}, category="guarded"},
    paladins={display="Paladins", words={"paladin","paladins"}, category="dutiful"},
    priests={display="Priests", words={"priest","priests","cleric","clerics"}, category="faith"},
    druids={display="Druids", words={"druid","druids"}, category="nature"},
    shamans={display="Shamans", words={"shaman","shamans"}, category="superstitious"},
    rogues={display="Rogues", words={"rogue","rogues"}, category="guarded"},
    warriors={display="Warriors", words={"warrior","warriors","fighter","fighters"}, category="danger"},
    hunters={display="Hunters", words={"hunter","hunters","ranger","rangers"}, category="nature"},
    monks={display="Monks", words={"monk","monks"}, category="reflective"},
    deathknights={display="Death Knights", words={"death knight","death knights"}, category="guarded"},
    demonhunters={display="Demon Hunters", words={"demon hunter","demon hunters"}, category="guarded"},
    evokers={display="Evokers", words={"evoker","evokers"}, category="arcane"},
    mercenaries={display="Mercenaries", words={"mercenary","mercenaries","hired blade","hired blades"}, category="practical"},
    adventurers={display="Adventurers", words={"adventurer","adventurers"}, category="travel"},
    knights={display="Knights", words={"knight","knights"}, category="dutiful"},

    humans={display="Humans", words={"human","humans"}, category="social"},
    dwarves={display="Dwarves", words={"dwarf","dwarves","dwarven"}, category="social"},
    gnomes={display="Gnomes", words={"gnome","gnomes","gnomish"}, category="social"},
    nightelves={display="Night Elves", words={"night elf","night elves","kaldorei"}, category="social"},
    highelves={display="High Elves", words={"high elf","high elves","quel'dorei","queldorei"}, category="social"},
    voidelves={display="Void Elves", words={"void elf","void elves","ren'dorei","rendorei"}, category="social"},
    draenei={display="Draenei", words={"draenei"}, category="social"},
    worgen={display="Worgen", words={"worgen"}, category="social"},
    pandaren={display="Pandaren", words={"pandaren","pandaria","pandarian"}, category="social"},
    orcs={display="Orcs", words={"orc","orcs"}, category="social"},
    trolls={display="Trolls", words={"troll","trolls","trollish"}, category="social"},
    tauren={display="Tauren", words={"tauren"}, category="social"},
    goblins={display="Goblins", words={"goblin","goblins","goblin-made"}, category="social"},
    forsaken={display="Forsaken", words={"forsaken"}, category="social"},
    vulpera={display="Vulpera", words={"vulpera"}, category="social"},
    alliance={display="The Alliance", words={"the alliance","alliance soldier","alliance soldiers"}, category="dutiful"},
    horde={display="The Horde", words={"the horde","horde soldier","horde soldiers"}, category="dutiful"},

    stormwind={display="Stormwind", words={"stormwind"}, category="home"},
    boralus={display="Boralus", words={"boralus"}, category="home"},
    dalaran={display="Dalaran", words={"dalaran"}, category="arcane"},
    silvermoon={display="Silvermoon", words={"silvermoon"}, category="home"},
    ironforge={display="Ironforge", words={"ironforge"}, category="home"},
    farms={display="Farm Life", words={"farm life","farm","farms","farming","homestead","homesteading"}, category="home"},
    citylife={display="City Life", words={"city life","living in the city","city living","urban life"}, category="social"},
    inns={display="Inns", words={"inn","inns","innkeeper","innkeepers"}, category="comfort"},
    markets={display="Markets", words={"market","markets","bazaar","bazaars","marketplace"}, category="money"},
    libraries={display="Libraries", words={"library","libraries","archive","archives"}, category="knowledge"},
}

RPAL.PERSONALITY_ASSOCIATION_TOPICS = TOPICS

RPAL.PERSONALITY_ASSOCIATION_GROUPS = {
    {name="EVERYDAY LIFE",keys={"food","sweets","meat","vegetables","spicyfood","drink","alcohol","tea","coffee","cooking","smoking","taverns","inns","home","farms","citylife","work","routine","sleep","warmth","cold","heat","money","wealth","poverty","markets","shopping","fashion","jewelry","solitude","silence"}},
    {name="PEOPLE & SOCIETY",keys={"family","friendship","loyalty","betrayal","elders","strangers","crowds","crowdsocial","romance","commonfolk","merchants","scholars","healers","guards","military","criminals","pirates","mercenaries","adventurers","knights","nobility","authority","leadership","teamwork","solitudework","etiquette","diplomacy","politics","bureaucracy","trade"}},
    {name="BELIEFS & VALUES",keys={"duty","freedom","justice","revenge","mercy","charity","honor","honesty","lies","faith","light","naaru","power","fame","reputation","order","chaos","secrets","gossip","tradition","change","risk","peace","war"}},
    {name="KNOWLEDGE & CRAFT",keys={"knowledge","books","libraries","craft","technology","medicine","archaeology","artifacts","alchemy","art","theater","music","dancing","poetry","storytelling","games","training"}},
    {name="MAGIC & THE UNKNOWN",keys={"magic","mages","warlocks","paladins","priests","druids","shamans","deathknights","demonhunters","evokers","fel","void","shadow","necromancy","bloodmagic","illusions","enchantment","undead","demons","dragons","elementals","spirits","portals","titans","oldgods"}},
    {name="WORLD & TRAVEL",keys={"nature","animals","wilderness","mountains","beaches","deserts","swamps","caves","graveyards","ruins","gardens","rooftops","travel","adventure","sea","ships","flying","horses","fishing","hunting","rain","snow","fire","night","storms","cities","stormwind","boralus","dalaran","silvermoon","ironforge"}},
    {name="CONFLICT & COMPETITION",keys={"danger","violence","dueling","weapons","armor","competition","gambling","warriors","rogues","hunters","monks"}},
    {name="PEOPLES & FACTIONS",keys={"humans","dwarves","darkiron","gnomes","mechagnomes","nightelves","highelves","bloodelves","voidelves","draenei","lightforged","worgen","kultirans","pandaren","dracthyr","earthen","orcs","trolls","tauren","goblins","forsaken","vulpera","alliance","horde","kirintor","argentcrusade","cenarion","earthenring","illidari","shadopan","explorersleague","si7"}},
}

local POSITIVE = {"love","like","likes","liked","enjoy","enjoys","adore","admire","respect","trust","prefer","good","great","useful","beautiful","wonderful","excellent","nice","helpful","interesting","fascinating","miss","support","protect","welcome","fond"}
local NEGATIVE = {"hate","hates","hated","dislike","dislikes","despise","loathe","distrust","fear","fears","avoid","avoids","bad","awful","terrible","dangerous","filthy","useless","stupid","annoying","disgusting","corrupt","corrupted","wrong","creepy","horrible"}
local NEGATORS = {"not","never","no","don't","dont","doesn't","doesnt","isn't","isnt","wasn't","wasnt","can't","cant","cannot","won't","wont","wouldn't","wouldnt","shouldn't","shouldnt","couldn't","couldnt","aren't","arent","weren't","werent"}
local INTENSIFIERS = {"really","very","absolutely","completely","deeply","truly","extremely","fucking","bloody","utterly","especially","particularly","seriously","genuinely","quite","so damn","damn well"}


-- ------------------------- SEMANTIC-LITE PARSER ----------------------------
-- Persona Learning deliberately uses a transparent, deterministic parser.
-- The parser is clause-aware and tracks negation, uncertainty, reported speech,
-- past-state language, contrast and comparisons. This makes long RP sentences
-- much less likely to leak sentiment from one subject into another.
local HEDGES = {"maybe","perhaps","might","could","i guess","i suppose","i think","probably","possibly","seems","seem","apparently","kind of","sort of","a little","somewhat","in a way","i reckon","i imagine"}
local STRONG_UNCERTAINTY = {"i don't know if","i do not know if","not sure if","i'm not sure if","i am not sure if","hard to say","who knows","can't tell if","cannot tell if"}
local REPORTED_SPEECH = {"people say","they say","they said","he says","he said","she says","she said","someone says","someone said","i heard","i've heard","i have heard","according to","word is","rumor says","rumour says"}
local PAST_STATE = {"used to","back then","in the past","once upon a time","years ago","when i was younger","when i was young","before all this"}
local CONTRAST_MARKERS = {" but "," however "," although "," though "," yet "," whereas "," instead "," on the other hand "," except "}
local CLAUSE_MARKERS = {" but "," however "," although "," though "," yet "," whereas "," instead "," on the other hand "," except "," and i "," and i'm "," and i am "," and i'd "," and i would "," because i "," so i "," while i "," even if i "," unless i "}
local FIRST_PERSON_MARKERS = {" i "," i'm "," i am "," i've "," i have "," i'd "," i would "," i'll "," i will "," my "," me "}

local SENTIMENT = {
    love=1.35, adore=1.40, admire=1.00, respect=.92, trust=1.12, prefer=.92,
    like=.92, likes=.92, liked=.92, enjoy=.95, enjoys=.95, fond=.90,
    good=.62, great=.80, useful=.72, beautiful=.72, wonderful=.92, excellent=.94,
    nice=.54, helpful=.70, interesting=.62, fascinating=.86, miss=.82,
    support=.68, protect=.72, welcome=.58,
    hate=-1.38, hates=-1.38, hated=-1.38, dislike=-1.02, dislikes=-1.02,
    despise=-1.38, loathe=-1.42, distrust=-1.08, fear=-.88, fears=-.88,
    avoid=-.72, avoids=-.72, bad=-.62, awful=-.92, terrible=-.94,
    dangerous=-.58, filthy=-.82, useless=-.76, stupid=-.66, annoying=-.72,
    disgusting=-1.02, corrupt=-.78, corrupted=-.78, wrong=-.50, creepy=-.62,
    horrible=-.96,
    reliable=.68, trustworthy=.92, dependable=.72, pleasant=.56, friendly=.62,
    reassuring=.54, impressive=.62, admirable=.74, sensible=.46, comforting=.58,
    unreliable=-.72, untrustworthy=-.96, arrogant=-.66, rude=-.62, cruel=-.84,
    hostile=-.70, unpleasant=-.58, irritating=-.60, reckless=-.46, suspicious=-.48,
    unsafe=-.58, pathetic=-.54, insufferable=-.82, troublesome=-.54,
}

local function stripInlineParentheticalOOC(text)
    -- In WoW RP, parenthetical fragments are overwhelmingly OOC. Fully
    -- parenthetical lines are rejected earlier; here we remove inline OOC such
    -- as "Hello there. (brb one sec)" while preserving the IC sentence.
    local previous
    repeat
        previous=text
        text=text:gsub("%b()", " ")
    until text==previous
    return trim(text:gsub("%s+", " "))
end

local function countWords(text)
    local n=0
    for _ in tostring(text or ""):gmatch("[%w']+") do n=n+1 end
    return n
end

local function sentenceBoundaryBefore(text,pos)
    local left=1
    for i=math.max(1,(pos or 1)-1),1,-1 do
        local c=text:sub(i,i)
        if c=="." or c=="!" or c=="?" or c==";" or c==":" then left=i+1 break end
    end
    return left
end

local function sentenceBoundaryAfter(text,pos)
    local right=#text
    for i=math.min(#text,(pos or 1)+1),#text do
        local c=text:sub(i,i)
        if c=="." or c=="!" or c=="?" or c==";" or c==":" then right=i-1 break end
    end
    return right
end

local function clauseBounds(text,pos)
    local left=sentenceBoundaryBefore(text,pos)
    local right=sentenceBoundaryAfter(text,pos)
    for _,marker in ipairs(CLAUSE_MARKERS) do
        local start=1
        while true do
            local a,b=text:find(marker,start,true)
            if not a then break end
            if b<(pos or 1) and b+1>left then left=b+1 end
            if a>(pos or 1) and a-1<right then right=a-1 end
            start=b+1
        end
    end
    left=math.max(1,left);right=math.max(left,math.min(#text,right))
    return left,right,text:sub(left,right)
end

local function containsAny(text,list)
    text=" "..tostring(text or "").." "
    for _,v in ipairs(list or {}) do
        if text:find(v,1,true) then return true,v end
    end
    return false
end

local function nearbyWordsBefore(text,pos,maxChars)
    local from=math.max(1,(pos or 1)-(maxChars or 46))
    return " "..text:sub(from,math.max(from,(pos or 1)-1)).." "
end

local function negationScope(text,pos)
    local left=clauseBounds(text,pos)
    local from=math.max(left or 1,(pos or 1)-48)
    local pre=" "..text:sub(from,math.max(from,(pos or 1)-1)).." "
    local hits=0
    for _,n in ipairs(NEGATORS) do
        local needle=" "..n.." "
        local start=1
        while true do
            local a,b=pre:find(needle,start,true)
            if not a then break end
            hits=hits+1;start=b+1
        end
    end
    -- "hardly" and "barely" are not clean logical negators; they weaken.
    local weak=(pre:find(" hardly ",1,true) or pre:find(" barely ",1,true)) and true or false
    return (hits%2)==1, weak and .58 or (hits>0 and .62 or 1)
end

local function semanticContextMultiplier(text,pos)
    local _,_,clause=clauseBounds(text,pos)
    local padded=" "..clause.." "
    local mult=1
    local strongUncertain=containsAny(padded,STRONG_UNCERTAINTY)
    if strongUncertain then mult=mult*.38
    elseif containsAny(padded,HEDGES) then mult=mult*.72 end
    if padded:find(" hypothetically ",1,true) or padded:find(" imagine ",1,true) or padded:find(" suppose ",1,true) then mult=mult*.50 end
    if padded:match("^%s*if%s") or padded:find(" if i ",1,true) or padded:find(" if we ",1,true) then mult=mult*.68 end
    if containsAny(padded,PAST_STATE) and not (padded:find(" still ",1,true) or padded:find(" always have ",1,true) or padded:find(" still do ",1,true)) then mult=mult*.44 end
    if containsAny(padded,REPORTED_SPEECH) and not (padded:find(" i agree ",1,true) or padded:find(" they're right ",1,true) or padded:find(" they are right ",1,true)) then mult=mult*.34 end
    if padded:find(" as if ",1,true) or padded:find(" yeah, right ",1,true) or padded:find(" sure, sure ",1,true) or padded:find(" obviously... ",1,true) then mult=mult*.42 end
    if padded:find(" not anymore ",1,true) or padded:find(" no longer ",1,true) then mult=mult*.58 end
    -- Questions about somebody else's preference are not the speaker's own belief.
    local q=trim(clause)
    if q:find("?",1,true) and (q:match("^do you ") or q:match("^did you ") or q:match("^would you ") or q:match("^could you ") or q:match("^can you ") or q:match("^are you ") or q:match("^is %w") or q:match("^what do you ") or q:match("^how do you ")) then mult=mult*.22 end
    -- Commands/advice can reveal a weak value signal, but are not equivalent to
    -- saying the character personally likes or dislikes the object.
    if (padded:find(" you should ",1,true) or padded:find(" you need to ",1,true) or padded:find(" you have to ",1,true) or padded:find(" you'd better ",1,true)) and not containsAny(padded,FIRST_PERSON_MARKERS) then mult=mult*.48 end
    -- Necessity/obligation is not preference: "I have to work with mages".
    if padded:find(" i have to ",1,true) or padded:find(" i need to ",1,true) or padded:find(" i must ",1,true) or padded:find(" i'm supposed to ",1,true) or padded:find(" i am supposed to ",1,true) then mult=mult*.74 end
    -- Later contrast clauses usually carry the speaker's actual conclusion.
    local before=text:sub(math.max(1,sentenceBoundaryBefore(text,pos)),math.max(1,(pos or 1)-1))
    for _,marker in ipairs(CONTRAST_MARKERS) do if before:find(marker,1,true) then mult=mult*1.10 break end end
    return clamp(mult,.18,1.20)
end

local function distanceMultiplier(distance)
    distance=math.max(0,tonumber(distance) or 0)
    if distance<=10 then return 1 end
    if distance<=24 then return .90 end
    if distance<=40 then return .72 end
    if distance<=58 then return .50 end
    return .28
end

local function looksLikeFiller(clause,pos,term)
    if term~="like" and term~="likes" then return false end
    local pre=clause:sub(math.max(1,pos-14),math.max(1,pos-1))
    local after=clause:sub(pos+#term,math.min(#clause,pos+#term+14))
    if pre:find("look",1,true) or pre:find("sound",1,true) or pre:find("seem",1,true) or pre:find("feel",1,true) then return true end
    if after:match("^%s+a%s") or after:match("^%s+an%s") then
        local fp=" "..clause.." "
        if not (fp:find(" i like ",1,true) or fp:find(" i really like ",1,true) or fp:find(" i quite like ",1,true)) then return true end
    end
    return false
end

local function explicitRelationBoost(clause,topicWord)
    local escaped=topicWord:gsub("([^%w])","%%%1")
    local score,mass=0,0
    local positivePatterns={
        "i%s+love%s+"..escaped,"i%s+like%s+"..escaped,"i%s+enjoy%s+"..escaped,
        "i%s+trust%s+"..escaped,"i%s+respect%s+"..escaped,"i%s+miss%s+"..escaped,
        "i%s+prefer%s+"..escaped,"fond%s+of%s+"..escaped,"nothing%s+against%s+"..escaped,
        "no%s+problem%s+with%s+"..escaped,"soft%s+spot%s+for%s+"..escaped,
        "comfortable%s+around%s+"..escaped,"feel%s+safe%s+around%s+"..escaped,
        "glad%s+to%s+have%s+"..escaped,"happy%s+to%s+have%s+"..escaped,
        "work%s+well%s+with%s+"..escaped,"fight%s+alongside%s+"..escaped,
        "fight%s+beside%s+"..escaped,"wouldn't%s+mind%s+"..escaped,"would%s+not%s+mind%s+"..escaped,
    }
    local negativePatterns={
        "i%s+hate%s+"..escaped,"i%s+dislike%s+"..escaped,"i%s+distrust%s+"..escaped,
        "i%s+fear%s+"..escaped,"i%s+avoid%s+"..escaped,"can't%s+stand%s+"..escaped,
        "cannot%s+stand%s+"..escaped,"sick%s+of%s+"..escaped,"tired%s+of%s+"..escaped,
        "fed%s+up%s+with%s+"..escaped,"rather%s+not%s+deal%s+with%s+"..escaped,
        "uncomfortable%s+around%s+"..escaped,"uneasy%s+around%s+"..escaped,
        "keep%s+away%s+from%s+"..escaped,"want%s+"..escaped.."%s+gone",
    }
    for _,pat in ipairs(positivePatterns) do if clause:find(pat) then score=score+1.18;mass=mass+1.18 end end
    for _,pat in ipairs(negativePatterns) do if clause:find(pat) then score=score-1.18;mass=mass+1.18 end end
    return score,mass
end

local function findTerm(text, term, startAt)
    startAt=startAt or 1
    while true do
        local a,b=text:find(term,startAt,true)
        if not a then return nil end
        local before=a<=1 and " " or text:sub(a-1,a-1)
        local after=b>=#text and " " or text:sub(b+1,b+1)
        if not before:match("[%w_]") and not after:match("[%w_]") then return a,b end
        startAt=b+1
    end
end

local function hasPhrase(text, phrase)
    if phrase:sub(1,1)==" " or phrase:sub(-1)==" " then return text:find(phrase,1,true)~=nil end
    return findTerm(text,phrase,1) ~= nil
end

local function phraseWeight(phrase)
    local words=0
    for _ in phrase:gmatch("[%w']+") do words=words+1 end
    if words>=4 then return 1.55 elseif words==3 then return 1.35 elseif words==2 then return 1.12 end
    return .72
end

local function negatedNear(text, at)
    local left=clauseBounds(text,at)
    local from=math.max(left or 1,(at or 1)-24)
    local pre=" "..text:sub(from,math.max(from,(at or 1)-1)).." "
    for _,n in ipairs(NEGATORS) do
        if pre:find(" "..n.." ",1,true) then return true end
    end
    return false
end

local function intensityNear(text, at)
    local left=clauseBounds(text,at)
    local from=math.max(left or 1,(at or 1)-24)
    local pre=" "..text:sub(from,math.max(from,(at or 1)-1)).." "
    for _,n in ipairs(INTENSIFIERS) do if pre:find(" "..n.." ",1,true) then return 1.28 end end
    return 1
end

local function migrateLanguageMarkerContamination(p,l,previousVersion)
    if previousVersion>=7 or l.languageMarkerMigrationAt then return end
    local markerTimes={};local cleanedRecent={}
    local function mentionsOrcs(value)
        local padded=" "..tostring(value or""):lower():gsub("[^%a']"," "):gsub("%s+"," ").." "
        return padded:find(" orc ",1,true)or padded:find(" orcs ",1,true)
    end
    for raw,at in pairs(type(l.recentTexts)=="table"and l.recentTexts or{})do
        local low=tostring(raw):lower();local cleaned=stripLanguageMarkers(raw)
        if low:find("[orcish]",1,true)and not mentionsOrcs(cleaned)then
            markerTimes[#markerTimes+1]=tonumber(at)or 0
        end
        if cleaned~=""then cleanedRecent[cleaned:sub(1,180)]=math.max(tonumber(cleanedRecent[cleaned:sub(1,180)])or 0,tonumber(at)or 0)end
    end
    l.recentTexts=cleanedRecent

    local row=type(l.associations)=="table"and l.associations.orcs or nil
    local contaminated=false
    if type(row)=="table"then
        local lastAt=tonumber(row.lastAt)or 0
        for _,at in ipairs(markerTimes)do if at>0 and math.abs(lastAt-at)<=30 then contaminated=true;break end end
    end
    local cleanedEvidence={}
    for _,e in ipairs(type(l.evidence)=="table"and l.evidence or{})do
        if type(e)=="table"then
            local raw=tostring(e.text or"");local cleaned=stripLanguageMarkers(raw)
            local falseOrc=e.kind=="association"and e.key=="orcs"and raw:lower():find("[orcish]",1,true)and not mentionsOrcs(cleaned)
            if falseOrc then contaminated=true;markerTimes[#markerTimes+1]=tonumber(e.at)or 0
            else e.text=cleaned;cleanedEvidence[#cleanedEvidence+1]=e end
        end
    end
    l.evidence=cleanedEvidence
    for _,d in ipairs(type(l.discourse)=="table"and l.discourse or{})do if type(d)=="table"then d.text=stripLanguageMarkers(d.text)end end

    local removedMemories,removedHistory=0,0
    if contaminated then
        l.associations.orcs=nil -- manualAssociations.orcs is intentionally preserved
        local kept={}
        for _,change in ipairs(type(l.changes)=="table"and l.changes or{})do
            local remove=type(change)=="table"and change.key=="orcs"
            if remove and#markerTimes>0 then
                remove=false;for _,at in ipairs(markerTimes)do if math.abs((tonumber(change.at)or 0)-at)<=90 then remove=true;break end end
            end
            if not remove then kept[#kept+1]=change end
        end
        l.changes=kept

        -- A false learned preference could already have produced a Character
        -- Memory and visible thought before this migration ran. Remove only
        -- those generated Orc-association echoes; explicit calibration remains.
        local c=type(p)=="table"and type(p.continuity)=="table"and p.continuity or nil
        if c and type(c.memories)=="table"then
            local memories={}
            for _,m in ipairs(c.memories)do
                local meta=type(m)=="table"and type(m.meta)=="table"and m.meta or{}
                local association=tostring(m and m.kind or"")=="association"
                local orcMemory=mentionsOrcs(meta.key)or mentionsOrcs(meta.label)or mentionsOrcs(m and m.text)
                if association and orcMemory then removedMemories=removedMemories+1 else memories[#memories+1]=m end
            end
            c.memories=memories
        end
        local removedAt={}
        if type(p)=="table"and type(p.history)=="table"then
            local history={}
            for _,h in ipairs(p.history)do
                local src=tostring(type(h)=="table"and h.source or""):lower()
                local generated=src:find("learned",1,true)or src:find("association",1,true)or src:find("character%-memory")
                if generated and mentionsOrcs(type(h)=="table"and(h.rawText or h.text)or nil)then
                    removedHistory=removedHistory+1;removedAt[tonumber(h.at)or 0]=true
                else history[#history+1]=h end
            end
            p.history=history
        end
        local q=c and type(c.cognition)=="table"and c.cognition or nil
        if q and type(q.whyHistory)=="table"then
            local why={}
            for _,h in ipairs(q.whyHistory)do if not(mentionsOrcs(h and h.text)and(removedAt[tonumber(h and h.at)or 0]or tostring(h and h.why and h.why.appraisal or""):lower():find("memory",1,true)))then why[#why+1]=h end end
            q.whyHistory=why
        end
        if q and type(q.decisions)=="table"then
            local decisions={}
            for _,d in ipairs(q.decisions)do if not(removedAt[tonumber(d and d.at)or 0]and tostring(d and d.source or""):lower():find("memory",1,true))then decisions[#decisions+1]=d end end
            q.decisions=decisions
        end
    end
    l.diagnostics=type(l.diagnostics)=="table"and l.diagnostics or{}
    l.diagnostics.migrations=type(l.diagnostics.migrations)=="table"and l.diagnostics.migrations or{}
    pushLimited(l.diagnostics.migrations,{at=time(),kind="language-marker-v7",orcishAssociationCleared=contaminated,markerRows=#markerTimes,removedMemories=removedMemories,removedHistory=removedHistory},12)
    l.languageMarkerMigrationAt=time()
end

local function ensureLearning()
    local p=RPAL.GetPersonalityData and RPAL.GetPersonalityData()
    if not p then return nil,nil end
    p.learning=type(p.learning)=="table" and p.learning or {}
    local l=p.learning
    local previousVersion=math.floor(tonumber(l.version) or 0)
    if previousVersion>0 and previousVersion<LEARNING_VERSION and not l.migratedToCleanLearningAt then
        l.migratedFromVersion=previousVersion;l.migratedToCleanLearningAt=time()
    end
    l.version=LEARNING_VERSION
    if l.enabled==nil then l.enabled=true end
    if l.calibrated==nil then l.calibrated=false end
    l.calibrationVersion=math.floor(tonumber(l.calibrationVersion) or (l.calibrated and 1 or 0))
    l.calibratedAt=tonumber(l.calibratedAt) or 0
    l.traits=type(l.traits)=="table" and l.traits or {}
    l.alignment=type(l.alignment)=="table" and l.alignment or {sum=0,mass=0,lastAt=0,lastNotified=""}
    l.associations=type(l.associations)=="table" and l.associations or {}
    l.manualAssociations=type(l.manualAssociations)=="table" and l.manualAssociations or {}
    l.people=type(l.people)=="table" and l.people or {}
    l.recentSpeakers=type(l.recentSpeakers)=="table" and l.recentSpeakers or {}
    l.locks=type(l.locks)=="table" and l.locks or {}
    l.stats=type(l.stats)=="table" and l.stats or {}
    l.stats.messages=tonumber(l.stats.messages) or 0
    l.stats.say=tonumber(l.stats.say) or 0
    l.stats.emote=tonumber(l.stats.emote) or 0
    l.stats.nearby=tonumber(l.stats.nearby) or 0
    l.stats.windowOwn=tonumber(l.stats.windowOwn) or 0
    l.stats.windowNearby=tonumber(l.stats.windowNearby) or 0
    l.stats.windowAt=tonumber(l.stats.windowAt) or time()
    l.stats.lastNearbyAt=tonumber(l.stats.lastNearbyAt) or 0
    l.stats.lastOwnAt=tonumber(l.stats.lastOwnAt) or 0
    l.changes=type(l.changes)=="table" and l.changes or {}
    l.evidence=type(l.evidence)=="table" and l.evidence or {}
    l.recentTexts=type(l.recentTexts)=="table" and l.recentTexts or {}
    -- Last ten IC sentence-units from this character.  This is intentionally
    -- small and ephemeral: it exists only to resolve genuine discourse
    -- continuity, never as a long-form chat archive.
    l.discourse=type(l.discourse)=="table" and l.discourse or {}
    while #l.discourse>10 do table.remove(l.discourse,1) end
    l.diagnostics=type(l.diagnostics)=="table" and l.diagnostics or {ignoredCounts={}}
    l.diagnostics.ignoredCounts=type(l.diagnostics.ignoredCounts)=="table" and l.diagnostics.ignoredCounts or {}
    l.diagnostics.ignored=nil
    migrateLanguageMarkerContamination(p,l,previousVersion)
    l.reactionCooldowns=type(l.reactionCooldowns)=="table" and l.reactionCooldowns or {}
    p.autonomy=type(p.autonomy)=="table" and p.autonomy or {}
    if p.autonomy.learning==nil then p.autonomy.learning=true end
    if p.autonomy.learningInsights==nil then p.autonomy.learningInsights=true end
    if p.autonomy.onlineReactions==nil then p.autonomy.onlineReactions=true end
    return p,l
end

function RPAL.GetPersonalityLearningData()
    local _,l=ensureLearning();return l
end

function RPAL.IsPersonalityCalibrated()
    local _,l=ensureLearning();return l and l.calibrated==true and (tonumber(l.calibrationVersion) or 0)>=CALIBRATION_VERSION or false
end

function RPAL.SetPersonalityLearningEnabled(value)
    local p,l=ensureLearning();if not l then return false end
    l.enabled=value and true or false
    if p and p.autonomy then p.autonomy.learning=l.enabled end
    if RPAL.RefreshPersonalityUI then RPAL.RefreshPersonalityUI() end
    return true
end

function RPAL.SetPersonalityLearningLock(key,value)
    local _,l=ensureLearning();if not l or l.calibrated or not RPAL.PERSONALITY_TRAIT_PAIRS or not RPAL.PERSONALITY_TRAIT_PAIRS[key] then return false end
    l.locks[key]=value and true or nil
    if RPAL.RefreshPersonalityUI then RPAL.RefreshPersonalityUI() end
    return true
end

function RPAL.IsPersonalityLearningLocked(key)
    local _,l=ensureLearning();return l and l.locks and l.locks[key] and true or false
end

local function decayEvidence(row)
    if type(row)~="table" then return end
    local now=time();local last=tonumber(row.decayAt) or tonumber(row.lastAt) or now
    local days=math.max(0,(now-last)/86400)
    if days>.25 then
        -- Slow evolution: old evidence remains meaningful, but the character can change.
        local factor=math.pow(.996,days)
        row.sum=(tonumber(row.sum) or 0)*factor
        row.mass=(tonumber(row.mass) or 0)*factor
        row.decayAt=now
    end
end

local function confidenceFromMass(mass,scale)
    mass=math.max(0,tonumber(mass) or 0);scale=scale or 30
    return clamp(1-math.exp(-mass/scale),0,1)
end

function RPAL.GetPersonalityTraitObservation(key)
    local _,l=ensureLearning();if not l then return nil end
    local row=l.traits[key]
    if type(row)~="table" then return {score=50,confidence=0,mass=0} end
    decayEvidence(row)
    local mass=math.max(0,tonumber(row.mass) or 0)
    local ratio=mass>0 and clamp((tonumber(row.sum) or 0)/mass,-1,1) or 0
    return {score=50+(ratio*50),confidence=confidenceFromMass(mass,34),mass=mass,sum=tonumber(row.sum) or 0,lastAt=row.lastAt}
end

function RPAL.GetEffectivePersonalityTrait(key)
    local p,l=ensureLearning();if not p then return 50 end
    local base=clamp((p.traits and p.traits[key]) or 50,0,100)
    if not l or not l.calibrated or not l.enabled or (p.autonomy and p.autonomy.learning==false) or l.locks[key] then return base end
    local obs=RPAL.GetPersonalityTraitObservation(key)
    local influence=.78*(obs.confidence or 0)
    return clamp(base*(1-influence)+(obs.score or 50)*influence,0,100)
end

function RPAL.GetPersonalityAlignmentObservation()
    local _,l=ensureLearning();if not l then return {good=.33,neutral=.34,evil=.33,confidence=0,mass=0} end
    local row=l.alignment;decayEvidence(row)
    local mass=math.max(0,tonumber(row.mass) or 0)
    local ratio=mass>0 and clamp((tonumber(row.sum) or 0)/mass,-1,1) or 0
    local extremity=math.abs(ratio)
    local neutral=clamp(.78-(extremity*.60),.12,.82)
    local side=1-neutral
    local good=(ratio>=0) and (side*(.5+.5*extremity)) or (side*(.5-.5*extremity))
    local evil=side-good
    return {good=good,neutral=neutral,evil=evil,confidence=confidenceFromMass(mass,42),mass=mass,score=ratio}
end

function RPAL.GetEffectivePersonalityAlignment()
    local p,l=ensureLearning();if not p then return nil end
    local base=p.alignment or {good=.34,neutral=.46,evil=.20}
    if not l or not l.calibrated or not l.enabled or (p.autonomy and p.autonomy.learning==false) then return base end
    local obs=RPAL.GetPersonalityAlignmentObservation();local influence=.68*(obs.confidence or 0)
    local out={good=base.good*(1-influence)+obs.good*influence,neutral=base.neutral*(1-influence)+obs.neutral*influence,evil=base.evil*(1-influence)+obs.evil*influence}
    local total=out.good+out.neutral+out.evil;if total<=0 then return base end
    out.good,out.neutral,out.evil=out.good/total,out.neutral/total,out.evil/total
    return out
end

local function associationObservation(key)
    local _,l=ensureLearning();if not l then return {score=0,confidence=0,mass=0} end
    local row=l.associations[key]
    if type(row)~="table" then return {score=0,confidence=0,mass=0} end
    decayEvidence(row)
    local mass=math.max(0,tonumber(row.mass) or 0)
    return {score=mass>0 and clamp((tonumber(row.sum) or 0)/mass,-1,1) or 0,confidence=confidenceFromMass(mass,8.5),mass=mass,lastAt=row.lastAt}
end

function RPAL.GetPersonalityAssociationWeight(key)
    local _,l=ensureLearning();if not l then return 0 end
    local manual=clamp(tonumber(l.manualAssociations[key]) or 0,-1,1)
    local obs=associationObservation(key)
    if not l.calibrated or not l.enabled then return manual end
    local influence=.82*(obs.confidence or 0)
    return clamp(manual*(1-influence)+(obs.score or 0)*influence,-1,1)
end

function RPAL.SetManualPersonalityAssociation(key,value)
    local _,l=ensureLearning();if not l or not TOPICS[key] or l.calibrated then return false end
    value=clamp(value,-1,1)
    if math.abs(value)<.05 then l.manualAssociations[key]=nil else l.manualAssociations[key]=value end
    if RPAL.RefreshPersonalityUI then RPAL.RefreshPersonalityUI() end
    return true
end

function RPAL.GetPersonalityTopAssociations(limit,positiveOnly)
    local _,l=ensureLearning();local out={};if not l then return out end
    for key,def in pairs(TOPICS) do
        local obs=associationObservation(key);local score=RPAL.GetPersonalityAssociationWeight(key)
        local manual=tonumber(l.manualAssociations[key]) or 0
        local conf=math.max(obs.confidence or 0,math.abs(manual)>.05 and .35 or 0)
        if conf>.12 and math.abs(score)>.12 and (not positiveOnly or score>0) then
            out[#out+1]={key=key,display=def.display,score=score,confidence=conf,mass=obs.mass,manual=manual,category=def.category,lastAt=obs.lastAt}
        end
    end
    table.sort(out,function(a,b)
        local av=math.abs(a.score)*(0.45+0.55*a.confidence);local bv=math.abs(b.score)*(0.45+0.55*b.confidence)
        if av==bv then return a.display<b.display end
        return av>bv
    end)
    limit=math.max(1,math.min(30,math.floor(tonumber(limit) or 10)))
    while #out>limit do table.remove(out) end
    return out
end

function RPAL.ApplyLearnedAssociationCategoryWeights(weights)
    if type(weights)~="table" then return end
    for key,def in pairs(TOPICS) do
        local score=RPAL.GetPersonalityAssociationWeight(key)
        if score>.12 and def.category and weights[def.category] then
            weights[def.category]=weights[def.category]+score*.34
        elseif score<-.45 and def.category and weights[def.category] then
            weights[def.category]=math.max(.02,weights[def.category]+score*.12)
        end
    end
end

local function logEvidence(l,kind,key,delta,weight,text,label)
    pushLimited(l.evidence,{at=time(),kind=kind,key=key,delta=delta,weight=weight,text=tostring(text or ""):sub(1,180),label=label},MAX_EVIDENCE_LOG)
end

local function pushChange(l,kind,title,detail,key,extra)
    local row={at=time(),kind=kind,title=title,detail=detail,key=key}
    if type(extra)=="table" then for k,v in pairs(extra) do row[k]=v end end
    pushLimited(l.changes,row,MAX_CHANGE_LOG)
    if RPAL.OnPersonalityLearningChange then pcall(RPAL.OnPersonalityLearningChange,row) end
end

function RPAL.GetPersonalityLearningChanges(limit)
    local _,l=ensureLearning();local out={};if not l then return out end
    limit=math.max(1,math.min(MAX_CHANGE_LOG,math.floor(tonumber(limit) or 8)))
    for i=math.max(1,#l.changes-limit+1),#l.changes do out[#out+1]=l.changes[i] end
    return out
end

local function traitState(key,value)
    local pair=RPAL.PERSONALITY_TRAIT_PAIRS and RPAL.PERSONALITY_TRAIT_PAIRS[key]
    if not pair then return "balanced","Balanced" end
    if value<=34 then return "left",pair.left elseif value>=66 then return "right",pair.right end
    return "balanced","Balanced"
end

local function maybeTraitChange(p,l,key,before,after)
    local row=l.traits[key];if not row then return end
    local obs=RPAL.GetPersonalityTraitObservation(key)
    if (obs.confidence or 0)<.18 then return end
    local oldState,oldLabel=traitState(key,before);local newState,newLabel=traitState(key,after)
    local last=row.lastNotifiedState
    if newState~="balanced" and newState~=last and (oldState~=newState or math.abs(after-before)>=4) then
        row.lastNotifiedState=newState
        local pair=RPAL.PERSONALITY_TRAIT_PAIRS[key]
        pushChange(l,"trait","Persona shift",(pair and (pair.left.." <-> "..pair.right) or key).." now leans "..newLabel.." ("..math.floor(after+.5).."%).",key,{before=before,after=after,confidence=obs.confidence,left=pair and pair.left or "Left",right=pair and pair.right or "Right",state=newState,label=newLabel})
    elseif last and newState=="balanced" and oldState~=newState then
        row.lastNotifiedState="balanced"
        local pair=RPAL.PERSONALITY_TRAIT_PAIRS[key]
        pushChange(l,"trait","Persona shift",(pair.left.." <-> "..pair.right).." has moved back toward balance.",key,{before=before,after=after,confidence=obs.confidence,left=pair.left,right=pair.right,state="balanced",label="Balanced"})
    end
end

local function alignmentState(value)
    value=value or {good=.33,neutral=.34,evil=.33}
    local g,n,e=tonumber(value.good) or 0,tonumber(value.neutral) or 0,tonumber(value.evil) or 0
    if g>=n and g>=e and g>=.42 then return "good","Good" end
    if e>=n and e>=g and e>=.42 then return "evil","Evil" end
    return "neutral","Neutral"
end

local function maybeAlignmentChange(l,before,after)
    local obs=RPAL.GetPersonalityAlignmentObservation()
    if (obs.confidence or 0)<.18 then return end
    local row=l.alignment
    local function distance(a,b)
        local dg=(tonumber(b.good)or 0)-(tonumber(a.good)or 0)
        local dn=(tonumber(b.neutral)or 0)-(tonumber(a.neutral)or 0)
        local de=(tonumber(b.evil)or 0)-(tonumber(a.evil)or 0)
        return math.sqrt((dg*dg)+(dn*dn)+(de*de))
    end
    local movement=distance(before,after)
    if movement<=.00001 then return end
    row.pendingMoralMovement=(tonumber(row.pendingMoralMovement)or 0)+movement
    if type(row.pendingMoralBefore)~="table"then row.pendingMoralBefore={good=before.good,neutral=before.neutral,evil=before.evil}end

    local oldState=alignmentState(row.pendingMoralBefore)
    local newState,newLabel=alignmentState(after)
    local now=time()
    local lastAt=tonumber(row.lastMoralToastAt)or 0
    local accumulated=tonumber(row.pendingMoralMovement)or 0
    -- Major direction changes surface immediately. Tiny per-line nudges accumulate
    -- so the player sees meaningful triangle movement without toast spam.
    local forceState=(newState~=oldState and newState~=row.lastNotifiedState)
    local enough=(accumulated>=.030 and now-lastAt>=45)or(accumulated>=.014 and now-lastAt>=180)
    if not forceState and not enough then return end

    local origin=row.pendingMoralBefore
    local dg=(tonumber(after.good)or 0)-(tonumber(origin.good)or 0)
    local dn=(tonumber(after.neutral)or 0)-(tonumber(origin.neutral)or 0)
    local de=(tonumber(after.evil)or 0)-(tonumber(origin.evil)or 0)
    local toward="Neutral";local best=dn
    if dg>best then toward="Good";best=dg end
    if de>best then toward="Evil";best=de end
    if best<=0 then
        local g,n,e=tonumber(after.good)or 0,tonumber(after.neutral)or 0,tonumber(after.evil)or 0
        toward=(g>=n and g>=e)and"Good"or((e>=g and e>=n)and"Evil"or"Neutral")
    end
    row.lastNotifiedState=newState;row.lastMoralToastAt=now;row.pendingMoralMovement=0;row.pendingMoralBefore=nil
    pushChange(l,"alignment","Moral Core moved","Repeated choices shifted the Moral Core toward "..toward..".","moral",{state=newState,label=newLabel,toward=toward,confidence=obs.confidence,beforeAlignment=origin,afterAlignment=after})
end

local function addTraitEvidence(p,l,key,direction,weight,text,label)
    if not RPAL.PERSONALITY_TRAIT_PAIRS[key] or l.locks[key] then return end
    local before=RPAL.GetEffectivePersonalityTrait(key)
    local row=l.traits[key];if type(row)~="table" then row={sum=0,mass=0,lastAt=time()};l.traits[key]=row end
    decayEvidence(row)
    weight=clamp(weight*(tonumber(l._currentWeight) or 1),.02,2.4);direction=clamp(direction,-1,1)
    row.sum=(tonumber(row.sum) or 0)+(direction*weight)
    row.mass=(tonumber(row.mass) or 0)+weight
    row.lastAt=time();row.decayAt=row.lastAt
    logEvidence(l,"trait",key,direction,weight,text,label)
    local after=RPAL.GetEffectivePersonalityTrait(key)
    if math.abs(after-before)>=.15 then maybeTraitChange(p,l,key,before,after) end
end

local function addAlignmentEvidence(p,l,direction,weight,text,label)
    local before=RPAL.GetEffectivePersonalityAlignment()
    local row=l.alignment;decayEvidence(row);weight=clamp(weight*(tonumber(l._currentWeight) or 1),.02,2.2);direction=clamp(direction,-1,1)
    row.sum=(tonumber(row.sum) or 0)+(direction*weight);row.mass=(tonumber(row.mass) or 0)+weight;row.lastAt=time();row.decayAt=row.lastAt
    logEvidence(l,"alignment","moral",direction,weight,text,label)
    local after=RPAL.GetEffectivePersonalityAlignment()
    maybeAlignmentChange(l,before,after)
end

function RPAL.AddPersonaMoralEvidence(direction,weight,text,label,meta)
    local p,l=ensureLearning();if not p or not l or not l.calibrated or not l.enabled or(p.autonomy and p.autonomy.learning==false)then return false end
    direction=clamp(direction,-1,1);weight=clamp(weight or .25,.02,1.8)
    local before=RPAL.GetEffectivePersonalityAlignment()
    local oldWeight=l._currentWeight;l._currentWeight=1
    addAlignmentEvidence(p,l,direction,weight,lowerClean(text),label or"cognitive moral action")
    l._currentWeight=oldWeight
    l.moralActions=type(l.moralActions)=="table"and l.moralActions or{}
    table.insert(l.moralActions,1,{at=time(),direction=direction,weight=weight,label=label,text=trim(text):sub(1,220),meta=type(meta)=="table"and meta or{}})
    while#l.moralActions>48 do table.remove(l.moralActions)end
    return true,before,RPAL.GetEffectivePersonalityAlignment()
end

local function associationState(score,conf)
    if conf<.30 or math.abs(score)<.34 then return "neutral" end
    if score>=.68 then return "love" elseif score>=.34 then return "like" elseif score<=-.68 then return "hate" elseif score<=-.34 then return "dislike" end
    return "neutral"
end

local function addAssociationEvidence(p,l,key,direction,weight,text,label)
    if not TOPICS[key] then return end
    local row=l.associations[key];if type(row)~="table" then row={sum=0,mass=0,lastAt=time()};l.associations[key]=row end
    decayEvidence(row);direction=clamp(direction,-1,1);weight=clamp(weight*(tonumber(l._currentWeight) or 1),.03,2.2)
    row.sum=(tonumber(row.sum) or 0)+(direction*weight);row.mass=(tonumber(row.mass) or 0)+weight;row.lastAt=time();row.decayAt=row.lastAt
    logEvidence(l,"association",key,direction,weight,text,label)
    local obs=associationObservation(key);local score=RPAL.GetPersonalityAssociationWeight(key);local state=associationState(score,obs.confidence)
    if state~=row.lastNotifiedState and state~="neutral" then
        row.lastNotifiedState=state
        local action=(state=="love" and "loves") or (state=="like" and "likes") or (state=="hate" and "hates") or "dislikes"
        pushChange(l,"association",(score>0 and "New preference" or "New aversion"),(RPAL.FullName and RPAL.FullName() or "This character").." "..action.." "..TOPICS[key].display..".",key,{score=score,confidence=obs.confidence,state=state,display=TOPICS[key].display})
    elseif state=="neutral" and row.lastNotifiedState and row.lastNotifiedState~="neutral" and (obs.confidence or 0)>.42 then
        row.lastNotifiedState="neutral"
        pushChange(l,"association","Preference softened",TOPICS[key].display.." no longer reads as a strong like or dislike.",key,{score=score,confidence=obs.confidence,state="neutral",display=TOPICS[key].display})
    end
end

local function findCueDirection(text, cues)
    local score,mass=0,0
    for side,list in pairs({left=cues.left or {},right=cues.right or {}}) do
        local baseDir=side=="right" and 1 or -1
        for _,phrase in ipairs(list) do
            local startAt=1
            while true do
                local at,b=findTerm(text,phrase,startAt)
                if not at then break end
                local dir=baseDir
                local w=phraseWeight(phrase)*intensityNear(text,at)*semanticContextMultiplier(text,at)
                local _,_,cueClause=clauseBounds(text,at);local cp=" "..cueClause.." "
                -- A trait adjective attached to another person is not evidence
                -- about the player character. Keep only a microscopic residual
                -- instead of being fooled by later phrases such as "not me".
                if cp:match("^%s*you are ") or cp:match("^%s*you're ") or cp:match("^%s*he is ") or cp:match("^%s*he's ") or cp:match("^%s*she is ") or cp:match("^%s*she's ") or cp:match("^%s*they are ") or cp:match("^%s*they're ") then w=w*.035 end
                local negated,negMult=negationScope(text,at)
                if negated then dir=-dir;w=w*negMult end
                if w>.08 then score=score+dir*w;mass=mass+w end
                startAt=(b or at)+1
            end
        end
    end
    if mass<=0 then return 0,0 end
    return clamp(score/mass,-1,1),math.min(2.4,mass)
end

local function applyActs(p,l,text,eventName)
    local sets={DIALOGUE_ACTS}
    if eventName=="CHAT_MSG_EMOTE" then sets[#sets+1]=EMOTE_ACTS end
    for _,set in ipairs(sets) do
        for _,act in ipairs(set) do
            local at=nil
            for _,pat in ipairs(act.patterns or {}) do
                at=text:find(pat,1,true)
                if not at and pat:sub(1,1)~=" " and pat:sub(-1)~=" " then at=findTerm(text,pat,1) end
                if at then break end
            end
            if at then
                local context=semanticContextMultiplier(text,at)
                if context>.20 then
                    for key,delta in pairs(act.traits or {}) do
                        addTraitEvidence(p,l,key,delta>=0 and 1 or -1,math.abs(delta)*1.45*context,text,act.label)
                    end
                    if act.moral and act.moral~=0 then addAlignmentEvidence(p,l,act.moral>=0 and 1 or -1,math.abs(act.moral)*1.5*context,text,act.label) end
                end
            end
        end
    end
end

local function topicPresent(text,def)
    for _,word in ipairs(def.words or {}) do
        local at=findTerm(text,word,1)
        if at then return at,word end
    end
    return nil
end

local function directTopicSentiment(text,word,at)
    local left,right,clause=clauseBounds(text,at)
    local topicLocal=math.max(1,at-left+1)
    local score,mass=0,0
    local q=trim(clause)
    -- sentenceBoundaryAfter intentionally returns the character before punctuation,
    -- so inspect the source text as well. Otherwise "Do you like mages?" loses
    -- its question mark before semantic classification and looks like a statement.
    local sentenceRight=sentenceBoundaryAfter(text,at)
    local isQuestion=(text:sub((sentenceRight or right)+1,(sentenceRight or right)+1)=="?")
    if isQuestion and (q:match("^do you ") or q:match("^did you ") or q:match("^would you ") or q:match("^could you ") or q:match("^can you ") or q:match("^are you ") or q:match("^what do you ") or q:match("^how do you ") or q:match("^what's your ") or q:match("^what is your ")) then return 0,0 end
    local escapedTopic=word:gsub("([^%w])","%%%1")
    local hostileAt=clause:find("killing%s+"..escapedTopic) or clause:find("kill%s+"..escapedTopic) or clause:find("slaying%s+"..escapedTopic) or clause:find("slay%s+"..escapedTopic) or clause:find("hunting%s+"..escapedTopic) or clause:find("hunt%s+"..escapedTopic) or clause:find("destroying%s+"..escapedTopic) or clause:find("destroy%s+"..escapedTopic) or clause:find("attacking%s+"..escapedTopic) or clause:find("attack%s+"..escapedTopic) or clause:find("fighting%s+"..escapedTopic) or clause:find("fight%s+"..escapedTopic)

    -- Explicit first-person constructions are high-confidence because they
    -- describe the character's own relation to a topic rather than narration.
    local explicitScore,explicitMass=explicitRelationBoost(clause,word)
    if explicitMass>0 then
        local ctx=semanticContextMultiplier(text,at)
        score=score+(explicitScore*ctx);mass=mass+(explicitMass*ctx)
    end

    for term,polarity in pairs(SENTIMENT) do
        local startAt=1
        while true do
            local sa,sb=findTerm(clause,term,startAt)
            if not sa then break end
            if not looksLikeFiller(clause,sa,term) then
                local mid=(sa+(sb or sa))*.5
                local distance=math.abs(mid-(topicLocal+(#word*.5)))
                local modifiesHostileAction=hostileAt and sa<hostileAt and (hostileAt-(sb or sa))<=18
                if distance<=62 and not modifiesHostileAction then
                    local pdir=polarity>=0 and 1 or -1
                    local wt=math.abs(polarity)*distanceMultiplier(distance)
                    local globalAt=left+sa-1
                    local negated,negMult=negationScope(text,globalAt)
                    if negated then pdir=-pdir;wt=wt*negMult end
                    wt=wt*semanticContextMultiplier(text,globalAt)*intensityNear(text,globalAt)
                    if wt>.05 then score=score+(pdir*wt);mass=mass+wt end
                end
            end
            startAt=(sb or sa)+1
        end
    end

    local padded=" "..clause.." "
    -- Common natural-language constructions that lexical sentiment alone
    -- handles poorly. These are intentionally moderate, not absolute.
    if padded:find(" not all ",1,true) and (padded:find(" bad ",1,true) or padded:find(" terrible ",1,true)) then score=score+.38;mass=mass+.55 end
    if padded:find(" nothing against ",1,true) or padded:find(" no problem with ",1,true) then score=score+.46;mass=mass+.58 end
    if padded:find(" don't mind ",1,true) or padded:find(" do not mind ",1,true) then score=score+.30;mass=mass+.48 end
    if padded:find(" don't care for ",1,true) or padded:find(" do not care for ",1,true) then score=score-.44;mass=mass+.56 end
    if padded:find(" makes me uneasy ",1,true) or padded:find(" make me uneasy ",1,true) or padded:find(" makes me nervous ",1,true) or padded:find(" make me nervous ",1,true) or padded:find(" puts me on edge ",1,true) or padded:find(" put me on edge ",1,true) then score=score-.46;mass=mass+.62 end
    if padded:find(" grew on me ",1,true) then score=score+.58;mass=mass+.66 end
    if padded:find(" can't get enough of ",1,true) or padded:find(" cannot get enough of ",1,true) then score=score+.88;mass=mass+.92 end
    if padded:find(" have a soft spot for ",1,true) or padded:find(" got a soft spot for ",1,true) then score=score+.72;mass=mass+.78 end
    if padded:find(" not my cup of tea ",1,true) or padded:find(" isn't my thing ",1,true) or padded:find(" is not my thing ",1,true) then score=score-.58;mass=mass+.68 end
    if padded:find(" could be worse ",1,true) or padded:find(" can't complain ",1,true) or padded:find(" cannot complain ",1,true) then score=score+.18;mass=mass+.38 end
    if padded:find(" makes me smile ",1,true) or padded:find(" puts me at ease ",1,true) or padded:find(" makes me feel safe ",1,true) then score=score+.52;mass=mass+.64 end
    if padded:find(" makes my skin crawl ",1,true) or padded:find(" turns my stomach ",1,true) or padded:find(" makes me sick ",1,true) or padded:find(" can't stand being around ",1,true) then score=score-.66;mass=mass+.76 end
    if padded:find(" saved my life ",1,true) or padded:find(" helped me when ",1,true) or padded:find(" protected me ",1,true) then score=score+.34;mass=mass+.52 end
    if padded:find(" betrayed me ",1,true) or padded:find(" lied to me ",1,true) or padded:find(" attacked me ",1,true) or padded:find(" tried to kill me ",1,true) then score=score-.42;mass=mass+.58 end
    if padded:find(" have their uses ",1,true) or padded:find(" has its uses ",1,true) or padded:find(" has their uses ",1,true) then score=score+.22;mass=mass+.42 end
    if padded:find(" keep my distance ",1,true) or padded:find(" keep our distance ",1,true) or padded:find(" rather keep away ",1,true) then score=score-.34;mass=mass+.50 end
    if padded:find(" don't agree with ",1,true) or padded:find(" do not agree with ",1,true) then score=score-.20;mass=mass+.38 end
    if padded:find(" can respect ",1,true) or padded:find(" i respect ",1,true) then score=score+.34;mass=mass+.48 end

    -- Hostile-object grammar matters: "I love killing demons" expresses
    -- enthusiasm for fighting, not affection for demons. Conversely, fighting
    -- alongside a group is weak positive social evidence.
    local esc=escapedTopic
    local fightingAlongside=clause:find("fight%s+alongside%s+"..esc) or clause:find("fighting%s+alongside%s+"..esc) or clause:find("fight%s+beside%s+"..esc) or clause:find("fighting%s+beside%s+"..esc) or clause:find("fight%s+with%s+"..esc) or clause:find("fighting%s+with%s+"..esc)
    if hostileAt and not fightingAlongside then
        if not (clause:find("hate%s+killing%s+"..esc) or clause:find("dislike%s+killing%s+"..esc) or clause:find("hate%s+fighting%s+"..esc) or clause:find("dislike%s+fighting%s+"..esc)) then score=score-.58;mass=mass+.72 end
    end
    if clause:find("alongside%s+"..esc) or clause:find("beside%s+"..esc) or clause:find("together%s+with%s+"..esc) or fightingAlongside then score=score+.30;mass=mass+.48 end

    if mass<=0 then return 0,0 end
    return clamp(score/mass,-1,1),math.min(2.35,mass)
end

RPAL.PERSONA_EMOTE_TOPIC_REACTIONS = RPAL.PERSONA_EMOTE_TOPIC_REACTIONS or {
    positive={"smiles at","grins at","lights up at","nods approvingly","nods at","leans toward","steps closer to","reaches for","accepts ","keeps ","examines with interest","studies with interest","admires ","pets ","strokes ","takes a sip","takes a drink","takes a bite","orders ","pours ","reads ","opens the book","hums along","listens closely"},
    negative={"grimaces at","scoffs at","sneers at","recoils from","flinches from","steps away from","backs away from","pushes away","refuses ","turns away from","looks disgusted at","looks uneasy around","glares at","spits at","throws away","drops immediately"},
}
RPAL.PERSONA_CONTEXT_REACTION_POSITIVE = RPAL.PERSONA_CONTEXT_REACTION_POSITIVE or {"nods","nods slowly","nods in agreement","smiles","grins","chuckles","laughs","looks pleased","seems pleased","relaxes","lights up","raises a glass","gives an approving nod","hums approvingly","murmurs agreement"}
RPAL.PERSONA_CONTEXT_REACTION_NEGATIVE = RPAL.PERSONA_CONTEXT_REACTION_NEGATIVE or {"shakes their head","shakes his head","shakes her head","shakes head","grimaces","scoffs","sneers","frowns","rolls their eyes","rolls his eyes","rolls her eyes","recoils","winces","looks uneasy","looks disgusted","clicks their tongue","clicks his tongue","clicks her tongue","mutters disapprovingly"}

local function collectTopicMentions(text)
    local mentions={}
    for key,def in pairs(TOPICS) do
        local bestAt,bestWord=nil,nil
        for _,word in ipairs(def.words or {}) do
            local at=findTerm(text,word,1)
            if at and (not bestAt or at<bestAt) then bestAt,bestWord=at,word end
        end
        if bestAt then mentions[#mentions+1]={key=key,at=bestAt,word=bestWord,def=def} end
    end
    table.sort(mentions,function(a,b)return a.at<b.at end)
    return mentions
end


-- ----------------------- TEN-SENTENCE DISCOURSE ---------------------------
-- Persona can now follow a subject across up to ten recent IC sentence-units,
-- but only when the window actually behaves like one conversation.  A repeated
-- topic, a clear pronoun/continuation cue, lexical overlap and recency all add
-- coherence.  If the window contains unrelated sentences or two competing
-- subjects with similar support, no contextual opinion is inferred.
local Discourse = {}
Discourse.maxRows=10
Discourse.maxAge=240
Discourse.stop={
    the=true,a=true,an=true,["and"]=true,["or"]=true,but=true,so=true,["if"]=true,["then"]=true,than=true,
    to=true,of=true,["in"]=true,on=true,at=true,["for"]=true,from=true,with=true,without=true,
    is=true,are=true,was=true,were=true,be=true,been=true,being=true,["do"]=true,does=true,did=true,
    have=true,has=true,had=true,will=true,would=true,could=true,should=true,can=true,may=true,might=true,
    i=true,me=true,my=true,mine=true,we=true,us=true,our=true,you=true,your=true,he=true,him=true,his=true,
    she=true,her=true,they=true,them=true,their=true,it=true,its=true,this=true,that=true,these=true,those=true,
    ["not"]=true,no=true,yes=true,very=true,really=true,just=true,about=true,there=true,here=true,what=true,who=true,
    where=true,when=true,why=true,how=true,some=true,any=true,all=true,one=true,thing=true,things=true,
}
Discourse.cues={"and ","but ","though ","still ","also ","exactly","right","yes","no","anyway","besides","because ","so ","then ","again","same ","that's why","that is why","even then","on top of that"}
Discourse.coref={" them "," they "," their "," it "," its "," that "," those "," these "," such "," the same "," one of them "," all of them "}

function Discourse.sentences(text)
    local out={}
    text=trim(tostring(text or ""))
    if text=="" then return out end
    local start=1
    for i=1,#text do
        local c=text:sub(i,i)
        if c=="." or c=="!" or c=="?" or c==";" then
            local row=trim(text:sub(start,i-1));if row~="" then out[#out+1]=row end
            start=i+1
        end
    end
    local tail=trim(text:sub(start));if tail~="" then out[#out+1]=tail end
    if #out==0 then out[1]=text end
    return out
end

function Discourse.tokens(text)
    local out={}
    for token in tostring(text or ""):gmatch("[%a']+") do
        token=token:lower():gsub("^'+",""):gsub("'+$","")
        if #token>=3 and not Discourse.stop[token] then out[token]=true end
    end
    return out
end

function Discourse.overlap(a,b)
    local shared,ac,bc=0,0,0
    for k in pairs(a or {}) do ac=ac+1;if b and b[k] then shared=shared+1 end end
    for _ in pairs(b or {}) do bc=bc+1 end
    if shared==0 then return 0,0 end
    return shared,shared/math.max(1,ac+bc-shared)
end

function Discourse.hasCoref(text)
    local padded=" "..tostring(text or "").." "
    for _,v in ipairs(Discourse.coref) do if padded:find(v,1,true) then return true end end
    return false
end

function Discourse.hasCue(text)
    local t=trim(tostring(text or ""))
    for _,v in ipairs(Discourse.cues) do if t:sub(1,#v)==v then return true end end
    return false
end

function Discourse.prune(l)
    local now=time();local w=l.discourse or {};local clean={}
    for _,row in ipairs(w) do
        if now-(tonumber(row.at) or 0)<=Discourse.maxAge then clean[#clean+1]=row end
    end
    while #clean>Discourse.maxRows do table.remove(clean,1) end
    l.discourse=clean;return clean
end

function Discourse.resolve(l,sentence,mentions)
    local w=Discourse.prune(l)
    if #w==0 or #(mentions or {})>0 then return nil end
    local tokens=Discourse.tokens(sentence);local coref=Discourse.hasCoref(sentence);local cue=Discourse.hasCue(sentence)
    local scores,support,nearest,overlaps={},{},{},{}
    for i=#w,1,-1 do
        local row=w[i];local distance=#w-i+1;local age=time()-(tonumber(row.at) or 0)
        local shared,jacc=Discourse.overlap(tokens,row.tokens or {})
        for _,key in ipairs(row.topics or {}) do
            local decay=math.max(.42,1-((distance-1)*.065)) * math.max(.50,1-(age/520))
            local base=(row.explicit and 1.08 or .66)*decay
            if coref then base=base+(.34*decay) end
            if cue then base=base+(.18*decay) end
            if shared>=2 then base=base+(.38+math.min(.28,jacc*.55)) elseif shared==1 then base=base+.10 end
            scores[key]=(scores[key] or 0)+base
            support[key]=(support[key] or 0)+1
            if not nearest[key] or distance<nearest[key] then nearest[key]=distance end
            overlaps[key]=math.max(overlaps[key] or 0,shared)
        end
    end
    local best,second=nil,nil
    for key,score in pairs(scores) do
        local row={key=key,score=score,support=support[key] or 0,nearest=nearest[key] or 99,overlap=overlaps[key] or 0}
        if not best or row.score>best.score then second=best;best=row elseif not second or row.score>second.score then second=row end
    end
    if not best then return nil end
    -- A current sentence has to actually connect to the preceding discourse.
    -- Repeated history alone never hijacks an unrelated new thought.
    local linked=coref or cue or best.overlap>=1
    if not linked then return nil end
    -- Measure whether the candidate is a *current thread*, not merely a noun
    -- that happened to appear twice somewhere in the ten-line memory.
    local trailingBreaks=0
    for i=#w,1,-1 do
        local found=false;for _,key in ipairs(w[i].topics or {}) do if key==best.key then found=true;break end end
        if found then break end
        trailingBreaks=trailingBreaks+1
    end
    local recentRows=math.min(6,#w);local recentSupport=0
    for i=#w-recentRows+1,#w do
        for _,key in ipairs(w[i].topics or {}) do if key==best.key then recentSupport=recentSupport+1;break end end
    end
    local density=recentSupport/math.max(1,recentRows)
    -- One immediate antecedent + a pronoun is valid (the existing short-memory
    -- case). Longer carry requires a recurring, reasonably dense thread.
    local shortCoref=(coref and best.nearest<=2)
    if best.support<2 and not shortCoref then return nil end
    if not shortCoref and density<.34 and recentSupport<3 then return nil end
    if trailingBreaks>=2 and best.overlap==0 and not cue then return nil end
    if trailingBreaks>=3 and best.overlap<2 then return nil end
    -- Competing subjects mean the last ten lines are not coherent enough to
    -- guess.  This is the main guard against forcing context on independent RP.
    if second and second.score>0 and best.score<(second.score*1.32+.22) then return nil end
    local coherence=clamp((best.score/(1.7+best.support*.72)),0,1)
    coherence=coherence*clamp(.72+density*.52,.72,1.08)
    if best.support>=3 then coherence=clamp(coherence+.10,0,1) end
    if best.overlap>=2 then coherence=clamp(coherence+.10,0,1) end
    if coherence<.36 then return nil end
    best.coherence=coherence;best.coref=coref;best.cue=cue
    return best
end

function Discourse.contextReaction(sentence,eventName)
    if eventName~="CHAT_MSG_EMOTE" then return 0,0 end
    local padded=" "..sentence.." ";local dir=0
    for _,pat in ipairs(RPAL.PERSONA_CONTEXT_REACTION_POSITIVE or {}) do if padded:find(pat,1,true) then dir=1;break end end
    if dir==0 then for _,pat in ipairs(RPAL.PERSONA_CONTEXT_REACTION_NEGATIVE or {}) do if padded:find(pat,1,true) then dir=-1;break end end end
    if dir==0 then return 0,0 end
    return dir,.13
end

function Discourse.push(l,sentence,mentions,resolved)
    local topics={};local explicit=false
    if #(mentions or {})==1 then topics[1]=mentions[1].key;explicit=true
    elseif resolved and resolved.key and (resolved.coherence or 0)>=.46 then topics[1]=resolved.key end
    local row={text=sentence:sub(1,220),at=time(),topics=topics,explicit=explicit,tokens=Discourse.tokens(sentence)}
    l.discourse=l.discourse or {};l.discourse[#l.discourse+1]=row
    while #l.discourse>Discourse.maxRows do table.remove(l.discourse,1) end
end

function Discourse.process(p,l,text,eventName)
    for _,sentence in ipairs(Discourse.sentences(text)) do
        local mentions=collectTopicMentions(sentence)
        local resolved=Discourse.resolve(l,sentence,mentions)
        if resolved and resolved.key and TOPICS[resolved.key] then
            local word=(TOPICS[resolved.key].words and TOPICS[resolved.key].words[1]) or resolved.key
            local synthetic=word.." "..sentence
            local dir,wt=directTopicSentiment(synthetic,word,1)
            local shortAge=time()-(tonumber(l.stats.lastOwnTopicAt) or 0)
            local duplicatedShort=(l.stats.lastOwnTopicKey==resolved.key and shortAge<=22 and resolved.coref)
            if not duplicatedShort and wt>.16 and math.abs(dir)>.14 then
                addAssociationEvidence(p,l,resolved.key,dir,wt*(.30+.34*resolved.coherence),text,"coherent discourse context")
            elseif eventName=="CHAT_MSG_EMOTE" then
                local rdir,rwt=Discourse.contextReaction(sentence,eventName)
                if rwt>0 then addAssociationEvidence(p,l,resolved.key,rdir,rwt*(.70+.30*resolved.coherence),text,"coherent discourse reaction") end
            end
        end
        Discourse.push(l,sentence,mentions,resolved)
    end
end

function RPAL.GetPersonaDiscourseWindow()
    local _,l=ensureLearning();local out={};if not l then return out end
    for _,row in ipairs(Discourse.prune(l)) do out[#out+1]={text=row.text,at=row.at,topics=row.topics,explicit=row.explicit} end
    return out
end

local function applyComparativeAssociations(p,l,text,mentions)
    -- Elliptical reversals such as "I used to hate mages, but not anymore" or
    -- "I liked them once, but now I don't" are common in RP. They should soften
    -- old-state evidence rather than freezing the character in the past.
    for _,a in ipairs(mentions) do
        local after=text:sub(a.at+#a.word,math.min(#text,a.at+#a.word+92))
        if after:find(" but not anymore",1,true) or after:find(" not anymore",1,true) or after:find(" no longer",1,true) or after:find(" but now i don't",1,true) or after:find(" but now i do not",1,true) then
            local dir,weight=directTopicSentiment(text,a.word,a.at)
            if weight>.18 and math.abs(dir)>.20 then addAssociationEvidence(p,l,a.key,-dir,.34,text,"changed opinion") end
        elseif after:find(" but i was wrong",1,true) or after:find(" turns out i was wrong",1,true) then
            local dir,weight=directTopicSentiment(text,a.word,a.at)
            if weight>.18 and math.abs(dir)>.20 then addAssociationEvidence(p,l,a.key,-dir,.40,text,"revised opinion") end
        end
    end
    if #mentions<2 then return end
    for i=1,#mentions-1 do
        local a,b=mentions[i],mentions[i+1]
        local from=math.max(1,a.at-34)
        local segment=text:sub(from,math.min(#text,b.at+#b.word+28))
        local between=text:sub(a.at+#a.word,math.max(a.at+#a.word,b.at-1))
        local beforeA=text:sub(from,math.max(from,a.at-1))
        local context=semanticContextMultiplier(text,a.at)
        if context>.22 then
            if (beforeA:find(" prefer ",1,true) or beforeA:match("^%s*prefer%s")) and (between:find(" to ",1,true) or between:find(" over ",1,true)) then
                addAssociationEvidence(p,l,a.key,1,.88*context,text,"stated preference")
                addAssociationEvidence(p,l,b.key,-1,.70*context,text,"comparative preference")
            elseif (beforeA:find(" rather ",1,true) or segment:find(" would rather ",1,true) or segment:find(" i'd rather ",1,true)) and between:find(" than ",1,true) then
                addAssociationEvidence(p,l,a.key,1,.76*context,text,"stated preference")
                addAssociationEvidence(p,l,b.key,-1,.62*context,text,"comparative preference")
            elseif between:find(" better than ",1,true) then
                addAssociationEvidence(p,l,a.key,1,.66*context,text,"comparative preference")
                addAssociationEvidence(p,l,b.key,-1,.52*context,text,"comparative preference")
            elseif between:find(" worse than ",1,true) then
                addAssociationEvidence(p,l,a.key,-1,.66*context,text,"comparative preference")
                addAssociationEvidence(p,l,b.key,1,.52*context,text,"comparative preference")
            elseif between:find(" more than ",1,true) then
                local dir,weight=directTopicSentiment(text,a.word,a.at)
                if weight>.16 and math.abs(dir)>.18 then
                    addAssociationEvidence(p,l,a.key,dir,.48*context,text,"comparative emphasis")
                    addAssociationEvidence(p,l,b.key,-dir,.34*context,text,"comparative emphasis")
                end
            elseif between:find(" but not ",1,true) then
                local dir,weight=directTopicSentiment(text,a.word,a.at)
                if weight>.16 and math.abs(dir)>.18 then addAssociationEvidence(p,l,b.key,-dir,.54*context,text,"contrastive preference") end
            end
        end
    end
end

local function analyzeAssociations(p,l,text,eventName)
    local mentions=collectTopicMentions(text)
    for _,hit in ipairs(mentions) do
        local direction,weight=directTopicSentiment(text,hit.word,hit.at)
        if weight>.12 and math.abs(direction)>.12 then
            addAssociationEvidence(p,l,hit.key,direction,weight,text,"topic sentiment")
        end

        -- Topic carry inside the same message. This resolves safe elliptical
        -- continuations such as "Mages? I don't trust them" and mixed reads
        -- such as "Mages are useful, but dangerous." It only runs when one
        -- topic is present, so sentiment is not guessed between multiple nouns.
        if #mentions==1 then
            local after=text:sub(hit.at+#hit.word,math.min(#text,hit.at+#hit.word+170))
            local cleaned=trim(after:gsub("^[%s%.%!%?;:,-]+",""))
            local tail=cleaned
            local _,contrastEnd=tail:find("^but%s+");if contrastEnd then tail=trim(tail:sub(contrastEnd+1)) end
            local _,howeverEnd=tail:find("^however%s+");if howeverEnd then tail=trim(tail:sub(howeverEnd+1)) end
            local _,thoughEnd=tail:find("^though%s+");if thoughEnd then tail=trim(tail:sub(thoughEnd+1)) end
            local hasPronoun=findTerm(tail,"them",1) or findTerm(tail,"they",1) or findTerm(tail,"it",1) or findTerm(tail,"that",1) or findTerm(tail,"those",1)
            if hasPronoun then
                local synthetic=hit.word.." "..tail
                local dir2,w2=directTopicSentiment(synthetic,hit.word,1)
                if w2>.16 and math.abs(dir2)>.14 then addAssociationEvidence(p,l,hit.key,dir2,w2*.62,text,"pronoun-linked sentiment") end
            end
            -- A contrast clause can continue the same subject without a pronoun:
            -- "Mages are useful, but dangerous."
            local contrastTail=cleaned:match(".-%s+but%s+(.+)$") or cleaned:match(".-%s+however%s+(.+)$") or cleaned:match(".-%s+yet%s+(.+)$")
            if contrastTail and #contrastTail<100 then
                local synthetic=hit.word.." "..contrastTail
                local dir3,w3=directTopicSentiment(synthetic,hit.word,1)
                if w3>.16 and math.abs(dir3)>.14 then addAssociationEvidence(p,l,hit.key,dir3,w3*.52,text,"contrast carry") end
            end
        end

        -- /emote often communicates attitude without saying "I like X".
        -- Repeated approach/approval or recoil/disapproval around a topic is
        -- treated as deliberately weak evidence and must accumulate.
        if eventName=="CHAT_MSG_EMOTE" then
            local left,_,clause=clauseBounds(text,hit.at);local ep=" "..clause.." ";local eScore,eMass=0,0;local topicLocal=math.max(1,hit.at-left+2)
            for _,pat in ipairs((RPAL.PERSONA_EMOTE_TOPIC_REACTIONS and RPAL.PERSONA_EMOTE_TOPIC_REACTIONS.positive)or{})do local pa=ep:find(pat,1,true);if pa and math.abs(pa-topicLocal)<=34 then eScore=eScore+.34;eMass=eMass+.46 end end
            for _,pat in ipairs((RPAL.PERSONA_EMOTE_TOPIC_REACTIONS and RPAL.PERSONA_EMOTE_TOPIC_REACTIONS.negative)or{})do local pa=ep:find(pat,1,true);if pa and math.abs(pa-topicLocal)<=34 then eScore=eScore-.38;eMass=eMass+.50 end end
            if eMass>0 then addAssociationEvidence(p,l,hit.key,clamp(eScore/eMass,-1,1),math.min(.72,eMass),text,"emote reaction") end
        end
    end
    applyComparativeAssociations(p,l,text,mentions)

    -- Reaction-to-context learning: if the previous nearby RP line had one
    -- clear topic, a quick /emote reaction can contribute tiny evidence even
    -- when the emote itself omits the noun. Agreement/disagreement with an
    -- explicit prior sentiment is stronger than an ambiguous facial reaction.
    if eventName=="CHAT_MSG_EMOTE" and #mentions==0 then
        local age=time()-(tonumber(l.stats.lastRemoteTopicAt)or 0);local key=l.stats.lastRemoteTopicKey
        if key and age<=12 then
            local ep=" "..text.." ";local reaction=0
            for _,pat in ipairs(RPAL.PERSONA_CONTEXT_REACTION_POSITIVE or{})do if ep:find(pat,1,true) then reaction=1;break end end
            if reaction==0 then for _,pat in ipairs(RPAL.PERSONA_CONTEXT_REACTION_NEGATIVE or{})do if ep:find(pat,1,true) then reaction=-1;break end end end
            if reaction~=0 then
                local remoteDir=tonumber(l.stats.lastRemoteTopicDirection)or 0
                local dir=(math.abs(remoteDir)>=.18) and (reaction*remoteDir) or reaction
                local wt=(math.abs(remoteDir)>=.18) and .24 or .11
                addAssociationEvidence(p,l,key,dir>=0 and 1 or -1,wt,text,"reaction to recent topic")
            end
        end
    end

    -- Short discourse memory lets a second line resolve an obvious pronoun
    -- without pretending to solve arbitrary coreference. Example:
    --   "Mages are everywhere tonight."  ->  "I don't trust them."
    if #mentions==1 then
        l.stats.lastOwnTopicKey=mentions[1].key;l.stats.lastOwnTopicWord=mentions[1].word;l.stats.lastOwnTopicAt=time()
    elseif #mentions==0 then
        local age=time()-(tonumber(l.stats.lastOwnTopicAt)or 0)
        local key=l.stats.lastOwnTopicKey;local word=l.stats.lastOwnTopicWord
        if key and word and age<=22 and (findTerm(text,"them",1) or findTerm(text,"they",1) or findTerm(text,"it",1) or findTerm(text,"those",1) or findTerm(text,"that",1)) then
            local synthetic=word.." "..text
            local dir,w=directTopicSentiment(synthetic,word,1)
            if w>.18 and math.abs(dir)>.15 then addAssociationEvidence(p,l,key,dir,w*.58,text,"recent-topic pronoun") end
        end
    end
end

local function behavioralEvidence(p,l,text,eventName)
    local st=l.stats;local now=time();local sinceNearby=now-(tonumber(st.lastNearbyAt) or 0)
    -- Initiating speech with no nearby line just before it is weak extroversion evidence.
    if sinceNearby>18 then addTraitEvidence(p,l,"introvertedExtroverted",1,.10,text,"initiated speech") end
    if text:find("?",1,true) then addTraitEvidence(p,l,"introvertedExtroverted",1,.07,text,"asked a question") end
    if eventName=="CHAT_MSG_EMOTE" then
        if text:find(" at ",1,true) or text:find(" to ",1,true) or text:find(" toward",1,true) then addTraitEvidence(p,l,"introvertedExtroverted",1,.06,text,"directed emote") end
    end
    st.windowOwn=(tonumber(st.windowOwn) or 0)+1
    local total=(tonumber(st.windowOwn) or 0)+(tonumber(st.windowNearby) or 0)
    if total>=18 then
        local ratio=st.windowOwn/math.max(1,total)
        if ratio<=.20 then addTraitEvidence(p,l,"introvertedExtroverted",-1,.26,text,"quiet in local conversation")
        elseif ratio>=.48 then addTraitEvidence(p,l,"introvertedExtroverted",1,.24,text,"high local participation") end
        st.windowOwn,st.windowNearby=0,0;st.windowAt=now
    end
end

local function messageEvidenceWeight(l,text,eventName)
    local now=time();local key=text:sub(1,180);local last=tonumber(l.recentTexts[key]) or 0;local weight=1
    if now-last<1200 then weight=weight*.20 end
    l.recentTexts[key]=now
    local words=countWords(text)
    if words<=2 then weight=weight*.38 elseif words<=4 then weight=weight*.64 elseif words>=60 then weight=weight*.74 elseif words>=36 then weight=weight*.86 end
    if text:find("?",1,true) and not text:find("!",1,true) then weight=weight*.86 end
    if text:find(" maybe ",1,true) or text:find(" perhaps ",1,true) or text:find(" might ",1,true) or text:find(" hypothetically ",1,true) or text:find(" imagine ",1,true) then weight=weight*.76 end
    if containsAny(" "..text.." ",STRONG_UNCERTAINTY) then weight=weight*.66 end
    -- Multi-sentence emotes can carry several independent signals. We let the
    -- clause parser find them, but keep one long paragraph from becoming ten
    -- messages worth of evidence.
    local punctuation=0;for _ in text:gmatch("[%.%!%?%;]") do punctuation=punctuation+1 end
    if punctuation>=4 then weight=weight*.86 end
    if eventName=="CHAT_MSG_EMOTE" then weight=weight*1.08 end
    return clamp(weight,.10,1.15)
end

local learnRelationshipFromOwnLine

function RPAL.AnalyzePersonalityRoleplayMessage(text,eventName,apply)
    local p,l=ensureLearning();if not p or not l then return nil end
    eventName=tostring(eventName or "CHAT_MSG_SAY")
    local classification=inputClassification(text)
    if classification.ignored then if apply then logIgnored(l,text,classification.reason,eventName) end;return {ignored=true,reason=classification.reason} end
    text=stripLanguageMarkers(lowerClean(text))
    if text=="" then return {ignored=true,reason="empty"} end
    text=stripInlineParentheticalOOC(text)
    if text=="" then return {ignored=true,reason="ooc"} end
    local result={traits={},topics={},acts={},text=text}
    if apply then l._currentWeight=messageEvidenceWeight(l,text,eventName) end
    for key,cues in pairs(TRAIT_CUES) do
        local dir,mass=findCueDirection(text,cues)
        if mass>0 and math.abs(dir)>.08 then result.traits[key]={direction=dir,weight=mass} end
    end
    if apply then
        for key,row in pairs(result.traits) do addTraitEvidence(p,l,key,row.direction,row.weight,text,"language cue") end
        applyActs(p,l,text,eventName)
        analyzeAssociations(p,l,text,eventName)
        Discourse.process(p,l,text,eventName)
        behavioralEvidence(p,l,text,eventName)
        l._currentWeight=nil
    end
    return result
end

function RPAL.LearnPersonalityFromOwnMessage(text,eventName)
    local p,l=ensureLearning();if not p or not l or (RPAL.IsPersonaSystemEnabled and not RPAL.IsPersonaSystemEnabled()) or not l.enabled or not (RPAL.IsPersonalityCalibrated and RPAL.IsPersonalityCalibrated()) or (p.autonomy and p.autonomy.learning==false) then return false,"not_calibrated" end
    if eventName~="CHAT_MSG_SAY" and eventName~="CHAT_MSG_EMOTE" then return false,"unsupported_channel" end
    if not characterIsIC() then logIgnored(l,text,"character_ooc",eventName);return false,"character_ooc" end
    local classification=inputClassification(text);if classification.ignored then logIgnored(l,text,classification.reason,eventName);return false,classification.reason end
    l.stats.messages=l.stats.messages+1
    if eventName=="CHAT_MSG_SAY" then l.stats.say=l.stats.say+1 else l.stats.emote=l.stats.emote+1 end
    l.stats.lastOwnAt=time()
    local cleaned=stripLanguageMarkers(lowerClean(text))
    RPAL.AnalyzePersonalityRoleplayMessage(text,eventName,true)
    learnRelationshipFromOwnLine(l,cleaned,eventName)
    if RPAL.RefreshPersonalityUI and RPAL.IsPersonalityOpen and RPAL.IsPersonalityOpen() then RPAL.RefreshPersonalityUI() end
    return true
end

function RPAL.GetPersonalityLearningSummary()
    local p,l=ensureLearning();if not p or not l then return {} end
    local totalMass=0;local confident=0
    for key in pairs(RPAL.PERSONALITY_TRAIT_PAIRS or {}) do
        local o=RPAL.GetPersonalityTraitObservation(key);totalMass=totalMass+(o.mass or 0);if (o.confidence or 0)>=.5 then confident=confident+1 end
    end
    local overall=clamp((1-math.exp(-(totalMass+(l.stats.messages*.18))/130))*100,0,100)
    return {calibrated=(RPAL.IsPersonalityCalibrated and RPAL.IsPersonalityCalibrated()) or false,enabled=l.enabled,messages=l.stats.messages,say=l.stats.say,emote=l.stats.emote,confidence=math.floor(overall+.5),confidentTraits=confident,lastOwnAt=l.stats.lastOwnAt}
end

function RPAL.CompletePersonalityCalibration(traits,alignment,associations)
    local p,l=ensureLearning();if not p or not l then return false end
    if type(traits)=="table" then
        for key in pairs(RPAL.PERSONALITY_TRAIT_PAIRS or {}) do if traits[key]~=nil then p.traits[key]=clamp(traits[key],0,100) end end
    end
    if type(alignment)=="table" then
        local g,n,e=tonumber(alignment.good) or .34,tonumber(alignment.neutral) or .46,tonumber(alignment.evil) or .20
        local total=g+n+e;if total>0 then p.alignment.good,p.alignment.neutral,p.alignment.evil=g/total,n/total,e/total end
    end
    if type(associations)=="table" then
        l.manualAssociations={}
        for key,value in pairs(associations) do
            if TOPICS[key] and math.abs(tonumber(value) or 0)>=.05 then l.manualAssociations[key]=clamp(value,-1,1) end
        end
    end
    l.calibrated=true;l.calibrationVersion=CALIBRATION_VERSION;l.calibratedAt=time();l.enabled=true
    if RPAL.FinalizePersonaLivingCalibration then RPAL.FinalizePersonaLivingCalibration() end
    if p.autonomy then
        p.autonomy.scene=true;p.autonomy.session=true;p.autonomy.social=true;p.autonomy.ambient=false
        p.autonomy.needs=true;p.autonomy.memory=true;p.autonomy.brief=false;p.autonomy.custom=false
        p.autonomy.learning=true;p.autonomy.learningInsights=true;p.autonomy.onlineReactions=true;p.autonomy.frequency="balanced"
    end
    pushChange(l,"calibration","Calibration complete","ODYN will now learn slowly from your own /say and /emote roleplay.","calibration")
    if RPAL.RefreshPersonalityUI then RPAL.RefreshPersonalityUI() end
    return true
end

function RPAL.ResetPersonalityLearning(keepCalibration)
    local p,l=ensureLearning();if not p or not l then return false end
    local calibrated=keepCalibration and l.calibrated or false;local calibrationVersion=keepCalibration and l.calibrationVersion or 0;local calibratedAt=keepCalibration and l.calibratedAt or 0;local manual=l.manualAssociations
    p.learning={version=LEARNING_VERSION,enabled=true,calibrated=calibrated,calibrationVersion=calibrationVersion,calibratedAt=calibratedAt,traits={},alignment={sum=0,mass=0,lastAt=0},associations={},manualAssociations=manual or {},people={},recentSpeakers={},locks={},stats={messages=0,say=0,emote=0,nearby=0,windowOwn=0,windowNearby=0,windowAt=time(),lastNearbyAt=0,lastOwnAt=0},changes={},evidence={},recentTexts={},discourse={},reactionCooldowns={},diagnostics={ignoredCounts={}}}
    if RPAL.RefreshPersonalityUI then RPAL.RefreshPersonalityUI() end
    return true
end

-- -------------------------- RELATIONSHIP MODEL ------------------------------
local SOCIAL_POSITIVE={
    "thank you","thanks","good to see you","glad to see you","i trust you","i like you","i missed you","i miss you","my friend","dear friend","you did well","well done","i appreciate you","i appreciate that","stay with me","i've got you","i have got you","you can count on me","welcome back","happy to see you","you matter to me","i believe you","i believe in you","i forgive you","you're my friend","you are my friend","i'm proud of you","i am proud of you","i'm glad you're here","i am glad you're here","i'm glad you are here","i am glad you are here","i owe you one","i enjoy your company","good having you around",
}
local SOCIAL_NEGATIVE={
    "shut up","go away","leave me alone","i hate you","i don't trust you","i do not trust you","i don't like you","i do not like you","i despise you","idiot","moron","fool","coward","pathetic","disgusting","get lost","stay away from me","i can't stand you","i cannot stand you","you annoy me","you disgust me","i don't want you here","i do not want you here","i wish you'd leave","i wish you would leave","i'll never forgive you","i will never forgive you","i'll kill you","i will kill you","you'll regret","you will regret",
}
local SOCIAL_EMOTE_POSITIVE={"smiles at","grins at","hugs ","embraces ","pats ","offers a hand","nods to","waves to","laughs with","leans closer to","sits beside","moves closer to","helps ","offers to","shakes hands with","bows to","salutes ","comforts ","checks on ","tends to ","protects ","stands beside"}
local SOCIAL_EMOTE_NEGATIVE={"glares at","snarls at","spits at","shoves ","strikes ","punches ","kicks ","backs away from","steps away from","turns away from","scoffs at","sneers at","grimaces at","recoils from","rolls their eyes at","rolls his eyes at","rolls her eyes at","ignores ","dismisses ","refuses ","keeps distance from"}

local function personConfidence(mass)
    return confidenceFromMass(mass,10.5)
end

local function personState(score,conf)
    if conf<.28 or math.abs(score)<.24 then return "uncertain" end
    if score>=.62 then return "fond" elseif score>=.30 then return "positive" elseif score<=-.62 then return "averse" elseif score<=-.30 then return "negative" end
    return "uncertain"
end

local function getPersonRow(l,key,display)
    if key=="" then return nil end
    local row=l.people[key]
    if type(row)~="table" then row={sum=0,mass=0,count=0,lastAt=0,display=display or key};l.people[key]=row end
    if display and display~="" then row.display=display end
    decayEvidence(row)
    return row
end

local function addPersonEvidence(l,key,display,direction,weight,reason)
    local row=getPersonRow(l,key,display);if not row then return end
    direction=clamp(direction,-1,1);weight=clamp(weight,.02,.90)
    local beforeMass=tonumber(row.mass) or 0
    row.sum=(tonumber(row.sum) or 0)+(direction*weight)
    row.mass=beforeMass+weight
    row.count=(tonumber(row.count) or 0)+1
    row.lastAt=time();row.decayAt=row.lastAt
    local score=row.mass>0 and clamp(row.sum/row.mass,-1,1) or 0
    local conf=personConfidence(row.mass)
    local state=personState(score,conf)
    if state~=row.lastNotifiedState and state~="uncertain" and conf>=.34 then
        row.lastNotifiedState=state
        local label=(state=="fond" and "Growing fondness") or (state=="positive" and "Positive impression") or (state=="averse" and "Growing aversion") or "Negative impression"
        pushChange(l,"relationship",label,(row.display or key).." now reads as a "..(score>=0 and "positive" or "negative").." personal association.",key,{score=score,confidence=conf,state=state,display=row.display or key})
    end
end

local function rememberRemoteSpeaker(l,author)
    local key=normalizeAuthor(author);if key=="" then return end
    local display=tostring(author or "Someone"):match("^[^-]+") or tostring(author or "Someone")
    l.recentSpeakers[key]={at=time(),display=display}
    l.stats.lastSpeakerKey=key;l.stats.lastSpeakerAt=time();l.stats.lastSpeakerDisplay=display
    for k,row in pairs(l.recentSpeakers) do if time()-(tonumber(row.at) or 0)>180 then l.recentSpeakers[k]=nil end end
end

local function socialValence(text,eventName)
    local score,mass=0,0
    local padded=" "..text.." "
    for _,pat in ipairs(SOCIAL_POSITIVE) do
        local at=padded:find(pat,1,true) or text:find(pat,1,true)
        if at then
            local w=.82*semanticContextMultiplier(text,math.max(1,at-1));score=score+w;mass=mass+w
        end
    end
    for _,pat in ipairs(SOCIAL_NEGATIVE) do
        local at=padded:find(pat,1,true) or text:find(pat,1,true)
        if at then
            local w=.88*semanticContextMultiplier(text,math.max(1,at-1));score=score-w;mass=mass+w
        end
    end
    if eventName=="CHAT_MSG_EMOTE" then
        for _,pat in ipairs(SOCIAL_EMOTE_POSITIVE) do
            local at=text:find(pat,1,true);if at then local w=.72*semanticContextMultiplier(text,at);score=score+w;mass=mass+w end
        end
        for _,pat in ipairs(SOCIAL_EMOTE_NEGATIVE) do
            local at=text:find(pat,1,true);if at then local w=.76*semanticContextMultiplier(text,at);score=score-w;mass=mass+w end
        end
    end
    if mass<=0 then return 0,0 end
    return clamp(score/mass,-1,1),math.min(1.35,mass*.30)
end

local function findLikelyAddressee(l,text)
    local now=time();local bestKey,bestDisplay,bestWeight=nil,nil,0
    for key,row in pairs(l.recentSpeakers or {}) do
        local age=now-(tonumber(row.at) or 0)
        if age<=90 then
            local display=tostring(row.display or "");local short=display:lower()
            if short~="" and findTerm(text,short,1) then
                local w=age<=30 and 1 or .78
                if w>bestWeight then bestKey,bestDisplay,bestWeight=key,display,w end
            end
        end
    end
    if bestKey then return bestKey,bestDisplay,bestWeight end
    local key=l.stats.lastSpeakerKey;local age=now-(tonumber(l.stats.lastSpeakerAt) or 0)
    if key and age<=24 then return key,l.stats.lastSpeakerDisplay or key,.42 end
    return nil
end

learnRelationshipFromOwnLine=function(l,text,eventName)
    local direction,weight=socialValence(text,eventName)
    if weight<=0 or math.abs(direction)<.20 then return end
    local key,display,targetWeight=findLikelyAddressee(l,text)
    if not key then return end
    addPersonEvidence(l,key,display,direction,weight*(targetWeight or .4),"direct interaction")
end

function RPAL.GetPersonaPeople(limit)
    local _,l=ensureLearning();local out={};if not l then return out end
    for key,row in pairs(l.people or {}) do
        decayEvidence(row)
        local mass=math.max(0,tonumber(row.mass) or 0)
        local score=mass>0 and clamp((tonumber(row.sum) or 0)/mass,-1,1) or 0
        local conf=personConfidence(mass)
        if (tonumber(row.count) or 0)>=2 and conf>.10 then out[#out+1]={key=key,display=row.display or key,score=score,confidence=conf,count=row.count,lastAt=row.lastAt,state=personState(score,conf)} end
    end
    table.sort(out,function(a,b)
        local av=math.abs(a.score)*(0.45+0.55*a.confidence);local bv=math.abs(b.score)*(0.45+0.55*b.confidence)
        if av==bv then return (a.lastAt or 0)>(b.lastAt or 0) end
        return av>bv
    end)
    limit=math.max(1,math.min(20,math.floor(tonumber(limit) or 8)))
    while #out>limit do table.remove(out) end
    return out
end

-- -------------------------- ONLINE SOCIAL READS ----------------------------
local function analyzeTopicSentimentsOnly(text)
    text=lowerClean(text);local hits={}
    if text=="" or isOOCParenthetical(text) then return hits end
    for key,def in pairs(TOPICS) do
        local at,word=topicPresent(text,def)
        if at then
            local direction,weight=directTopicSentiment(text,word,at)
            if weight>.25 and math.abs(direction)>.30 then hits[#hits+1]={key=key,direction=direction,weight=weight} end
        end
    end
    table.sort(hits,function(a,b)return a.weight>b.weight end)
    return hits
end

local function sendReaction(author,reaction,topic)
    if not author or author=="" then return false end
    local payload=table.concat({"R",reaction or "neutral",topic or ""},"|")
    if RPAL.SendAddonMessageSafe then return RPAL.SendAddonMessageSafe(REACTION_PREFIX,payload,"WHISPER",author,true) end
    if not C_ChatInfo or not C_ChatInfo.SendAddonMessage then return false end
    local ok,result=pcall(C_ChatInfo.SendAddonMessage,REACTION_PREFIX,payload,"WHISPER",author)
    return ok and (result==nil or result==true or tonumber(result)==0)
end

local function maybeReactToRemote(text,author,eventName)
    local p,l=ensureLearning();if not p or not l or (RPAL.IsPersonaSystemEnabled and not RPAL.IsPersonaSystemEnabled()) or not (RPAL.IsPersonalityCalibrated and RPAL.IsPersonalityCalibrated()) or not l.enabled or (p.autonomy and p.autonomy.onlineReactions==false) then return end
    if eventName~="CHAT_MSG_SAY" and eventName~="CHAT_MSG_EMOTE" then return end
    if isOOCParenthetical(text) then return end
    local hits=analyzeTopicSentimentsOnly(text);if #hits==0 then return end
    local senderKey=normalizeAuthor(author);if senderKey=="" then return end
    local now=time();local last=tonumber(l.reactionCooldowns[senderKey]) or 0;if now-last<85 then return end
    for _,hit in ipairs(hits) do
        local pref=RPAL.GetPersonalityAssociationWeight(hit.key);local obs=associationObservation(hit.key);local manual=tonumber(l.manualAssociations and l.manualAssociations[hit.key]) or 0;local prefConfidence=math.max(obs.confidence or 0,math.abs(manual)>.05 and .50 or 0)
        if prefConfidence>=.38 and math.abs(pref)>=.46 then
            local agreement=pref*hit.direction
            if math.abs(agreement)>=.28 then
                local display=tostring(author or senderKey):match("^[^-]+") or tostring(author or senderKey)
                addPersonEvidence(l,senderKey,display,agreement>0 and 1 or -1,math.min(.20,.07+math.abs(agreement)*.11),"value compatibility")
                local reaction=agreement>0 and "liked" or "disliked"
                -- Reactions are deliberately uncertain and sparse, not a mind-reading feed.
                if math.random()<.34 then
                    l.reactionCooldowns[senderKey]=now
                    sendReaction(author,reaction,hit.key)
                end
                return
            end
        end
    end
end

local function receiveReaction(sender,payload)
    local kind,reaction,topic=payload:match("^([^|]+)|([^|]+)|?(.*)$")
    if kind~="R" or (reaction~="liked" and reaction~="disliked") then return end
    local now=time();RPAL._personaIncomingReactionAt=RPAL._personaIncomingReactionAt or {}
    local key=normalizeAuthor(sender);if now-(RPAL._personaIncomingReactionAt[key] or 0)<55 then return end
    RPAL._personaIncomingReactionAt[key]=now
    local short=tostring(sender or "Someone"):match("^[^-]+") or tostring(sender or "Someone")
    if RPAL.GetOtherTRPCharacterName then
        local ok,res=pcall(RPAL.GetOtherTRPCharacterName,sender)
        if ok and res and tostring(res)~="" then short=tostring(res) end
    end
    local text=short.." might have "..reaction.." that."
    if RPAL.ShowPersonaInsightToast then RPAL.ShowPersonaInsightToast("SOCIAL READ",text,topic and TOPICS[topic] and TOPICS[topic].display or nil) elseif DEFAULT_CHAT_FRAME then DEFAULT_CHAT_FRAME:AddMessage("|cff8edbe8ODYN|r  "..text) end
end

local eventFrame=CreateFrame("Frame")
eventFrame:RegisterEvent("PLAYER_LOGIN")
local PERSONA_LEARNING_EVENTS={"CHAT_MSG_SAY","CHAT_MSG_EMOTE","CHAT_MSG_ADDON"}
function RPAL.SetPersonaLearningEventsEnabled(enabled)
    for _,eventName in ipairs(PERSONA_LEARNING_EVENTS) do
        if enabled then eventFrame:RegisterEvent(eventName) else eventFrame:UnregisterEvent(eventName) end
    end
    RPAL._personaLearningEventsEnabled=enabled and true or false
end
RPAL.SetPersonaLearningEventsEnabled(RPAL.IsPersonaSystemEnabled and RPAL.IsPersonaSystemEnabled() or false)
eventFrame:SetScript("OnEvent",function(_,event,...)
    if event=="PLAYER_LOGIN" then
        ensureLearning()
        if C_ChatInfo and C_ChatInfo.RegisterAddonMessagePrefix then pcall(C_ChatInfo.RegisterAddonMessagePrefix,REACTION_PREFIX) end
        return
    end
    if event=="CHAT_MSG_ADDON" then
        if RPAL.IsPersonaSystemEnabled and not RPAL.IsPersonaSystemEnabled() then return end
        local prefix,payload,channel,sender=...
        if prefix==REACTION_PREFIX and channel=="WHISPER" and sender and normalizeAuthor(sender)~=playerChatName() then receiveReaction(sender,payload or "") end
        return
    end
    if RPAL.IsPersonaSystemEnabled and not RPAL.IsPersonaSystemEnabled() then return end
    local msg,author=...
    local guid=select(12,...)
    local function ProcessLearningChat()
        if RPAL.IsPersonaSystemEnabled and not RPAL.IsPersonaSystemEnabled() then return end
        local mine=(guid and UnitGUID and guid==UnitGUID("player")) or normalizeAuthor(author)==playerChatName()
        local p,l=ensureLearning();if not l then return end
        if not characterIsIC() then return end
        if mine then
            RPAL.LearnPersonalityFromOwnMessage(msg,event)
        else
            local classification=inputClassification(msg);if classification.ignored then return end
            l.stats.nearby=(tonumber(l.stats.nearby) or 0)+1;l.stats.windowNearby=(tonumber(l.stats.windowNearby) or 0)+1;l.stats.lastNearbyAt=time()
            rememberRemoteSpeaker(l,author)
            local remoteText=stripInlineParentheticalOOC(lowerClean(msg or ""));local remoteMentions=collectTopicMentions(remoteText)
            if #remoteMentions==1 then
                local h=remoteMentions[1];local dir,wt=directTopicSentiment(remoteText,h.word,h.at)
                l.stats.lastRemoteTopicKey=h.key;l.stats.lastRemoteTopicAt=time();l.stats.lastRemoteTopicDirection=(wt>.12 and dir or 0);l.stats.lastRemoteTopicSpeaker=normalizeAuthor(author)
            elseif #remoteMentions>1 then
                l.stats.lastRemoteTopicKey=nil;l.stats.lastRemoteTopicDirection=0
            end
            maybeReactToRemote(msg,author,event)
        end
    end
    if RPAL.QueuePersonaWork then RPAL.QueuePersonaWork("learning",ProcessLearningChat) else ProcessLearningChat() end
end)

