local ADDON_NAME, RPAL = ...

-- ============================================================
-- Renown Atlas / Talent system
-- Kept in its own file so the large UI.lua top-level chunk remains below
-- WoW Lua's local-variable compiler limit.
-- ============================================================

RPAL.TALENT_VERSION = 6
RPAL.TALENT_POINTS_PER_LEVEL = 1
RPAL.TALENT_LEVEL_INTERVAL = 2

RPAL.TALENT_BRANCHES = {
    courtesy = {
        name = "The Courtly Path",
        short = "COURTESY",
        description = "Intentional gestures, etiquette and the quiet art of leaving a good first impression.",
        color = {0.94,0.73,0.34},
    },
    fellowship = {
        name = "The Fellowship Path",
        short = "FELLOWSHIP",
        description = "Shared sessions, new faces and the strange little sequences that turn a crowd into company.",
        color = {0.35,0.88,0.67},
    },
    chronicle = {
        name = "The Chronicle Path",
        short = "CHRONICLE",
        description = "Stories with edges: awkwardness, sorrow, notoriety, persistence and the marks that remain afterward.",
        color = {0.70,0.48,0.96},
    },
}

RPAL.MEMOIR_DEFINITIONS = {
    room_remembered = {
        id="room_remembered",
        branch="courtesy",
        title="The Room Remembered Your Name",
        subtitle="A memoir of first impressions",
        quote="You left before the candles guttered, yet your name stayed behind in every conversation.",
        icon=RPAL.ICONS.scroll,
    },
    courtesy_returned = {
        id="courtesy_returned",
        branch="courtesy",
        title="A Courtesy Returned",
        subtitle="A memoir of grace answered in kind",
        quote="Some reputations are built without a blade ever leaving its sheath.",
        icon=RPAL.ICONS.book,
    },
    open_chair = {
        id="open_chair",
        branch="fellowship",
        title="A Table With One Chair Left",
        subtitle="A memoir of belonging",
        quote="No invitation was spoken. Someone simply moved their cloak from the last empty chair.",
        icon=RPAL.ICONS.barrel,
    },
    night_stayed = {
        id="night_stayed",
        branch="fellowship",
        title="The Night Everyone Stayed",
        subtitle="A memoir of shared hours",
        quote="The hour grew unreasonable. Nobody seemed interested in leaving.",
        icon=RPAL.ICONS.book,
    },
    name_in_margin = {
        id="name_in_margin",
        branch="chronicle",
        title="A Name in the Margin",
        subtitle="A memoir of becoming part of the story",
        quote="Not every name belongs in the title. Some change the whole page from the margin.",
        icon=RPAL.ICONS.scroll,
    },
    last_lantern = {
        id="last_lantern",
        branch="chronicle",
        title="The Last Lantern Was Yours",
        subtitle="A memoir of endurance",
        quote="When the room went dark, one small light was still moving.",
        icon=RPAL.ICONS.lantern,
    },
}

-- x/y are atlas coordinates relative to the center keystone.
-- Minor nodes are deliberately numerous: they are the atlas' connective tissue
-- and provide the small +1-to-a-card bonuses requested for the PoE-like feel.
RPAL.TALENT_NODES = {
    origin = {
        id="origin", type="origin", branch="origin", x=0,y=0,cost=0,
        title="The First Page",
        short="BEGIN",
        description="",
        effectText="The center of your Renown Atlas. It is always active.",
        icon=RPAL.ICONS.book,
    },

    -- COURTESY: upper-left -------------------------------------------------
    c_nod = {
        id="c_nod",type="minor",branch="courtesy",x=-145,y=78,cost=1,parents={"origin"},
        title="Measured Nod",short="+1 /nod",
        description="",
        effectText="+1 Renown whenever a scored /nod or /yes card triggers.",
        effect={statBonus={nod=1}},icon=RPAL.ICONS.scroll,
    },
    c_wave = {
        id="c_wave",type="minor",branch="courtesy",x=-270,y=145,cost=1,parents={"c_nod","x_acknowledge"},anyParent=true,
        title="Open Palm",short="+1 /wave",
        description="",
        effectText="+1 Renown from scored /wave cards.",
        effect={statBonus={wave=1}},icon=RPAL.ICONS.scroll,
    },
    c_smile = {
        id="c_smile",type="minor",branch="courtesy",x=-390,y=218,cost=1,parents={"c_wave"},
        title="Warm Regard",short="+1 /smile",
        description="",
        effectText="+1 Renown from scored /smile cards.",
        effect={statBonus={smile=1}},icon=RPAL.ICONS.emoteHeart,
    },
    c_grace = {
        id="c_grace",type="major",branch="courtesy",x=-520,y=285,cost=2,parents={"c_smile"},
        title="Grace Under Notice",short="COURTLY",
        description="",
        effectText="/nod, /wave and /smile each gain another +1 Renown.",
        detailText="Passive. Any scored /nod, /wave or /smile that already awards Renown receives +1 additional Renown. This stacks with the smaller +1 nodes on the same path.",
        effect={statBonus={nod=1,wave=1,smile=1}},icon=RPAL.ICONS.book,
    },
    c_peer = {
        id="c_peer",type="minor",branch="courtesy",x=-645,y=350,cost=1,parents={"c_grace"},
        title="Held Gaze",short="+1 /peer",
        description="",
        effectText="+1 Renown from scored /peer cards.",
        effect={statBonus={peer=1}},icon=RPAL.ICONS.magnify,
    },
    c_shy = {
        id="c_shy",type="minor",branch="courtesy",x=-760,y=420,cost=1,parents={"c_peer"},
        title="Soft-Spoken Presence",short="+1 /shy",
        description="",
        effectText="+1 Renown from scored /shy cards.",
        effect={statBonus={shy=1}},icon=RPAL.ICONS.scroll,
    },
    c_threefold = {
        id="c_threefold",type="major",branch="courtesy",x=-875,y=492,cost=2,parents={"c_shy"},
        title="Threefold Greeting",short="3 VOICES",
        description="",
        effectText="If 3 different characters score /wave, /nod or /smile toward you within 12 sec, gain +6 Renown.",
        detailText="Trigger window: 12 seconds. Three different characters must score /wave, /nod or /smile toward you. The third distinct valid character completes the sequence and immediately awards +6 bonus Renown.",
        effect={threefoldGreeting=true},icon=RPAL.ICONS.book,
    },
    c_royal = {
        id="c_royal",type="reward",branch="courtesy",x=-995,y=570,cost=1,parents={"c_threefold"},
        title="Laurels of Courtesy",short="COSMETIC",
        description="",
        effectText="Cosmetic Reward: unlocks a Courtly collection of themes, aura and animation choices.",
        reward={kind="header",name="Royal"},
        rewards={{kind="header",name="Royal"},{kind="theme",name="Ivory & Gold"},{kind="theme",name="Arcane"},{kind="theme",name="Frost"},{kind="aura",name="Arcane Orbit"},{kind="animation",name="Exclaim"}},icon=RPAL.ICONS.token,
    },
    c_cheer = {
        id="c_cheer",type="minor",branch="courtesy",x=-930,y=390,cost=1,parents={"c_threefold"},
        title="Answer the Toast",short="+1 /cheer",
        description="",
        effectText="+1 Renown from scored /cheer cards.",
        effect={statBonus={cheer=1}},icon=RPAL.ICONS.trophy,
    },
    c_luminous = {
        id="c_luminous",type="major",branch="courtesy",x=-1055,y=342,cost=2,parents={"c_cheer"},
        title="Luminous Reputation",short="GENTLE FAME",
        description="",
        effectText="+1 Renown from /pet, /cheer and /dance cards.",
        detailText="Passive. /pet here means another character affectionately petting or patting your character's head. Every scored /pet, /cheer and /dance interaction gains +1 additional Renown.",
        effect={statBonus={pet=1,cheer=1,dance=1}},icon=RPAL.ICONS.star,
    },
    c_aura = {
        id="c_aura",type="reward",branch="courtesy",x=-1175,y=300,cost=1,parents={"c_luminous"},
        title="Golden Radiance",short="COSMETIC",
        description="",
        effectText="Cosmetic Reward: unlocks Golden Radiance plus a compact Courtly cosmetic bundle.",
        reward={kind="aura",name="Golden Radiance"},
        rewards={{kind="aura",name="Golden Radiance"},{kind="theme",name="Fel"},{kind="background",name="Moonlit Mist"},{kind="portrait",name="Gilded Compass"},{kind="animation",name="Question"}},icon=RPAL.ICONS.star,
    },
    c_memoir1 = {
        id="c_memoir1",type="memoir",branch="courtesy",x=-1295,y=255,cost=2,parents={"c_aura"},
        title="The Room Remembered Your Name",short="MEMOIR",
        description="",
        effectText="Memoir unlocked and displayed to other ODYN users inspecting this character.",
        memoir="room_remembered",icon=RPAL.ICONS.scroll,
    },
    c_memoir2 = {
        id="c_memoir2",type="memoir",branch="courtesy",x=-1115,y=660,cost=2,parents={"c_royal"},
        title="A Courtesy Returned",short="MEMOIR",
        description="",
        effectText="Memoir unlocked and displayed to other ODYN users inspecting this character.",
        memoir="courtesy_returned",icon=RPAL.ICONS.book,
    },

    -- FELLOWSHIP: upper-right ---------------------------------------------
    f_met1 = {
        id="f_met1",type="minor",branch="fellowship",x=145,y=78,cost=1,parents={"origin"},
        title="First Introductions",short="+1 MEET",
        description="",
        effectText="Characters Met awards +1 additional Renown.",
        effect={characterMetBonus=1},icon=RPAL.ICONS.scroll,
    },
    f_pet = {
        id="f_pet",type="minor",branch="fellowship",x=270,y=145,cost=1,parents={"f_met1","x_warm_regard"},anyParent=true,
        title="Fond Headpat",short="+1 /pet",
        description="",
        effectText="+1 Renown from scored /pet cards (the affectionate head-pat emote).",
        effect={statBonus={pet=1}},icon=RPAL.ICONS.emoteHeart,
    },
    f_dance = {
        id="f_dance",type="minor",branch="fellowship",x=390,y=218,cost=1,parents={"f_pet"},
        title="Join the Rhythm",short="+1 /dance",
        description="",
        effectText="+1 Renown from scored /dance cards.",
        effect={statBonus={dance=1}},icon=RPAL.ICONS.music,
    },
    f_laughter = {
        id="f_laughter",type="major",branch="fellowship",x=520,y=285,cost=2,parents={"f_dance"},
        title="Shared Laughter",short="5 SECOND TALE",
        description="",
        effectText="If 2 different characters laugh at you within 5 sec, gain Renown equal to meeting a new character (+10 base).",
        detailText="Trigger window: 5 seconds. Two different characters must laugh, giggle or chuckle at you. When the second distinct character lands inside the window, you gain the same base Renown as meeting a new character: +10.",
        effect={sharedLaughter=true},icon=RPAL.ICONS.music,
    },
    f_cheer = {
        id="f_cheer",type="minor",branch="fellowship",x=645,y=350,cost=1,parents={"f_laughter"},
        title="Raised Voices",short="+1 /cheer",
        description="",
        effectText="+1 Renown from scored /cheer cards.",
        effect={statBonus={cheer=1}},icon=RPAL.ICONS.barrel,
    },
    f_met2 = {
        id="f_met2",type="major",branch="fellowship",x=760,y=420,cost=2,parents={"f_cheer"},
        title="Familiar Roads",short="+2 MEET",
        description="",
        effectText="Characters Met awards +2 additional Renown.",
        detailText="Passive. Every time Characters Met successfully awards its normal Renown, add +2 more. This bonus stacks with other Characters Met talents.",
        effect={characterMetBonus=2},icon=RPAL.ICONS.map,
    },
    f_crowd = {
        id="f_crowd",type="major",branch="fellowship",x=875,y=492,cost=2,parents={"f_met2"},
        title="Center of the Circle",short="3 IN 10 SEC",
        description="",
        effectText="When 3 different characters direct targeted emotes at you within 10 sec, gain +5 Renown.",
        detailText="Trigger window: 10 seconds. Any three different characters may direct valid targeted emotes at you. The third distinct character completes the crowd exchange and awards +5 bonus Renown.",
        effect={crowdMoment=true},icon=RPAL.ICONS.people,
    },
    f_dream = {
        id="f_dream",type="reward",branch="fellowship",x=995,y=570,cost=1,parents={"f_crowd"},
        title="Dreamweave",short="COSMETIC",
        description="",
        effectText="Cosmetic Reward: unlocks a Fellowship bundle built around living, verdant presentation.",
        reward={kind="aura",name="Dreamweave"},
        rewards={{kind="aura",name="Dreamweave"},{kind="effect",name="Falling Petals"},{kind="theme",name="Emerald"},{kind="portrait",name="Kaldorei"},{kind="ornament",name="Kaldorei Vines"},{kind="background",name="Rosemist"}},icon=RPAL.ICONS.star,
    },
    f_peer = {
        id="f_peer",type="minor",branch="fellowship",x=930,y=390,cost=1,parents={"f_crowd"},
        title="Seen in Company",short="+1 /peer",
        description="",
        effectText="+1 Renown from scored /peer cards.",
        effect={statBonus={peer=1}},icon=RPAL.ICONS.magnify,
    },
    f_welcome = {
        id="f_welcome",type="major",branch="fellowship",x=1055,y=342,cost=2,parents={"f_peer"},
        title="Always One More Chair",short="+2 MEET",
        description="",
        effectText="Characters Met awards another +2 Renown.",
        detailText="Passive. Characters Met gains another +2 Renown. Combined with earlier Fellowship bonuses, introductions can become one of your strongest Renown sources.",
        effect={characterMetBonus=2},icon=RPAL.ICONS.barrel,
    },
    f_frame = {
        id="f_frame",type="reward",branch="fellowship",x=1175,y=300,cost=1,parents={"f_welcome"},
        title="Dreamwoven Frame",short="COSMETIC",
        description="",
        effectText="Cosmetic Reward: unlocks Dreamwoven, Moonwell Eye, and a second Fellowship presentation bundle.",
        reward={kind="portrait",name="Dreamwoven"},
        rewards={{kind="portrait",name="Dreamwoven"},{kind="eye",name="Moonwell Eye"},{kind="theme",name="Dream"},{kind="aura",name="Emerald Reverie"},{kind="platform",name="Arcane Convergence"},{kind="animation",name="Applaud"}},icon=RPAL.ICONS.customize,
    },
    f_memoir1 = {
        id="f_memoir1",type="memoir",branch="fellowship",x=1295,y=255,cost=2,parents={"f_frame"},
        title="A Table With One Chair Left",short="MEMOIR",
        description="",
        effectText="Memoir unlocked and displayed to other ODYN users inspecting this character.",
        memoir="open_chair",icon=RPAL.ICONS.barrel,
    },
    f_memoir2 = {
        id="f_memoir2",type="memoir",branch="fellowship",x=1115,y=660,cost=2,parents={"f_dream"},
        title="The Night Everyone Stayed",short="MEMOIR",
        description="",
        effectText="Memoir unlocked and displayed to other ODYN users inspecting this character.",
        memoir="night_stayed",icon=RPAL.ICONS.book,
    },

    -- CHRONICLE: downward --------------------------------------------------
    l_smirk = {
        id="l_smirk",type="minor",branch="chronicle",x=0,y=-145,cost=1,parents={"origin"},
        title="A Knowing Look",short="+1 /smirk",
        description="",
        effectText="+1 Renown from scored /smirk cards.",
        effect={statBonus={smirk=1}},icon=RPAL.ICONS.scroll,
    },
    l_rude = {
        id="l_rude",type="minor",branch="chronicle",x=-95,y=-265,cost=1,parents={"l_smirk","x_appraisal"},anyParent=true,
        title="Bad Manners, Good Story",short="+1 /rude",
        description="",
        effectText="+1 Renown from scored /rude cards.",
        effect={statBonus={rude=1}},icon=RPAL.ICONS.skull,
    },
    l_snicker = {
        id="l_snicker",type="minor",branch="chronicle",x=95,y=-265,cost=1,parents={"l_smirk","x_banter"},anyParent=true,
        title="Under the Breath",short="+1 /snicker",
        description="",
        effectText="+1 Renown from scored /snicker cards.",
        effect={statBonus={snicker=1}},icon=RPAL.ICONS.skull,
    },
    l_edge = {
        id="l_edge",type="major",branch="chronicle",x=0,y=-390,cost=2,parents={"l_rude","l_snicker"},anyParent=true,
        title="Stories With Edges",short="SHARP TONGUE",
        description="",
        effectText="/smirk, /snicker and /rude each gain another +1 Renown.",
        detailText="Passive. Every successfully scored /smirk, /snicker and /rude interaction receives +1 additional Renown. It stacks with their individual minor nodes.",
        effect={statBonus={smirk=1,snicker=1,rude=1}},icon=RPAL.ICONS.book,
    },
    l_cry = {
        id="l_cry",type="minor",branch="chronicle",x=-110,y=-510,cost=1,parents={"l_edge"},
        title="Rain on the Page",short="+1 /cry",
        description="",
        effectText="+1 Renown from scored /cry cards.",
        effect={statBonus={cry=1}},icon=RPAL.ICONS.star,
    },
    l_frown = {
        id="l_frown",type="minor",branch="chronicle",x=110,y=-510,cost=1,parents={"l_edge"},
        title="A Darkened Brow",short="+1 /frown",
        description="",
        effectText="+1 Renown from scored /frown cards.",
        effect={statBonus={frown=1}},icon=RPAL.ICONS.emoteFrown,
    },
    l_storybeat = {
        id="l_storybeat",type="major",branch="chronicle",x=0,y=-635,cost=2,parents={"l_cry","l_frown"},anyParent=true,
        title="Every Fifth Beat",short="5TH INTERACTION",
        description="",
        effectText="Every 5th successfully scored interaction grants +5 bonus Renown.",
        detailText="Persistent counter. Every successfully scored interaction advances the count by one. Each 5th scored interaction awards +5 bonus Renown, then counting continues toward the next fifth beat.",
        effect={storyBeat={every=5,bonus=5}},icon=RPAL.ICONS.replay,
    },
    l_no = {
        id="l_no",type="minor",branch="chronicle",x=-145,y=-750,cost=1,parents={"l_storybeat"},
        title="Refusal Has Weight",short="+1 /no",
        description="",
        effectText="+1 Renown from scored /no cards.",
        effect={statBonus={no=1}},icon=RPAL.ICONS.lock,
    },
    l_contrast = {
        id="l_contrast",type="major",branch="chronicle",x=145,y=-750,cost=2,parents={"l_storybeat"},
        title="The Turn of the Session",short="SORROW → JOY",
        description="",
        effectText="If /cry or /frown is followed within 8 sec by a different character smiling or cheering at you, gain +8 Renown.",
        detailText="Trigger window: 8 seconds. First, one character must /cry or /frown at you. Then a different character must /smile or /cheer at you before the window expires. Completing the reversal awards +8 bonus Renown.",
        effect={contrastMoment=true},icon=RPAL.ICONS.book,
    },
    l_ornament = {
        id="l_ornament",type="reward",branch="chronicle",x=-240,y=-860,cost=1,parents={"l_no"},
        title="Royal Filigree",short="COSMETIC",
        description="",
        effectText="Cosmetic Reward: unlocks Royal Filigree and a cold Chronicle bundle.",
        reward={kind="ornament",name="Royal Filigree"},
        rewards={{kind="ornament",name="Royal Filigree"},{kind="background",name="Frosted Glass"},{kind="effect",name="Snowfall"},{kind="animation",name="Kneel"},{kind="animation",name="Kiss"}},icon=RPAL.ICONS.key,
    },
    l_atmosphere = {
        id="l_atmosphere",type="reward",branch="chronicle",x=240,y=-860,cost=1,parents={"l_contrast"},
        title="Arcane Constellations",short="COSMETIC",
        description="",
        effectText="Cosmetic Reward: unlocks Arcane Constellations, Sunwell's Eye, and two authored ambient effects.",
        reward={kind="background",name="Arcane Constellations"},
        rewards={{kind="background",name="Arcane Constellations"},{kind="eye",name="Sunwell's Eye"},{kind="effect",name="Moon Dust"},{kind="effect",name="Arcane Dust"},{kind="tooltip",name="Ribbon Annotation"}},icon=RPAL.ICONS.map,
    },
    l_crest = {
        id="l_crest",type="major",branch="chronicle",x=0,y=-910,cost=2,parents={"l_storybeat"},
        title="A Ledger That Pays Its Scribe",short="+1 TOKEN / RENOWN",
        description="",
        effectText="Whenever Renown is awarded, gain +1 additional Token for each Renown received.",
        detailText="Passive economy talent. Whenever any source grants Renown, you also receive +1 extra Token for every Renown awarded. A +10 Renown event therefore gives +10 extra Tokens.",
        effect={crestPerRenown=1},icon=RPAL.ICONS.token,
    },
    l_memoir1 = {
        id="l_memoir1",type="memoir",branch="chronicle",x=-330,y=-1010,cost=2,parents={"l_ornament"},
        title="A Name in the Margin",short="MEMOIR",
        description="",
        effectText="Memoir unlocked and displayed to other ODYN users inspecting this character.",
        memoir="name_in_margin",icon=RPAL.ICONS.scroll,
    },
    l_memoir2 = {
        id="l_memoir2",type="memoir",branch="chronicle",x=330,y=-1010,cost=2,parents={"l_atmosphere"},
        title="The Last Lantern Was Yours",short="MEMOIR",
        description="",
        effectText="Memoir unlocked and displayed to other ODYN users inspecting this character.",
        memoir="last_lantern",icon=RPAL.ICONS.lantern,
    },

    -- INTERWOVEN INNER RING ------------------------------------------------
    -- These nodes deliberately braid the three opening paths together. A
    -- character can change direction early without having to return to Origin.
    x_acknowledge = {
        id="x_acknowledge",type="minor",branch="courtesy",x=-70,y=155,cost=1,parents={"c_nod","f_met1"},anyParent=true,
        title="Acknowledged Across the Room",short="+1 /nod",
        description="",
        effectText="+1 Renown from scored /nod or /yes cards.",
        effect={statBonus={nod=1}},icon=RPAL.ICONS.scroll,
    },
    x_attention = {
        id="x_attention",type="minor",branch="fellowship",x=70,y=190,cost=1,parents={"c_nod","f_met1"},anyParent=true,
        title="Attention Returned",short="+1 /peer",
        description="",
        effectText="+1 Renown from scored /peer cards.",
        effect={statBonus={peer=1}},icon=RPAL.ICONS.magnify,
    },
    x_warm_regard = {
        id="x_warm_regard",type="minor",branch="fellowship",x=210,y=105,cost=1,parents={"f_met1","l_smirk"},anyParent=true,
        title="A Softer Expression",short="+1 /smile",
        description="",
        effectText="+1 Renown from scored /smile cards.",
        effect={statBonus={smile=1}},icon=RPAL.ICONS.emoteHeart,
    },
    x_appraisal = {
        id="x_appraisal",type="minor",branch="chronicle",x=-150,y=-65,cost=1,parents={"c_nod","l_smirk"},anyParent=true,
        title="Measured Appraisal",short="+1 /eye",
        description="",
        effectText="+1 Renown from scored /eye cards.",
        effect={statBonus={eye=1}},icon=RPAL.ICONS.eyeFocus,
    },
    x_banter = {
        id="x_banter",type="minor",branch="chronicle",x=150,y=-70,cost=1,parents={"f_met1","l_smirk"},anyParent=true,
        title="A Quiet Snicker",short="+1 /snicker",
        description="",
        effectText="+1 Renown from scored /snicker cards.",
        effect={statBonus={snicker=1}},icon=RPAL.ICONS.skull,
    },
    x_affection = {
        id="x_affection",type="minor",branch="fellowship",x=235,y=35,cost=1,parents={"x_warm_regard","x_attention"},anyParent=true,
        title="Affection Without Ceremony",short="+1 /pet",
        description="",
        effectText="+1 Renown from scored /pet head-pat cards.",
        effect={statBonus={pet=1}},icon=RPAL.ICONS.emoteHeart,
    },

    -- COURTESY WEAVE -------------------------------------------------------
    c_nod2 = {id="c_nod2",type="minor",branch="courtesy",x=-335,y=92,cost=1,parents={"c_wave","x_acknowledge"},anyParent=true,title="Quiet Agreement",short="+1 /nod",description="",effectText="+1 Renown from scored /nod or /yes cards.",effect={statBonus={nod=1}},icon=RPAL.ICONS.scroll},
    c_peer2 = {id="c_peer2",type="minor",branch="courtesy",x=-440,y=132,cost=1,parents={"c_nod2","x_attention"},anyParent=true,title="Held Attention",short="+1 /peer",description="",effectText="+1 Renown from scored /peer cards.",effect={statBonus={peer=1}},icon=RPAL.ICONS.magnify},
    c_smile2 = {id="c_smile2",type="minor",branch="courtesy",x=-525,y=178,cost=1,parents={"c_peer2","c_smile"},anyParent=true,title="Polite Warmth",short="+1 /smile",description="",effectText="+1 Renown from scored /smile cards.",effect={statBonus={smile=1}},icon=RPAL.ICONS.emoteHeart},
    c_eye1 = {id="c_eye1",type="minor",branch="courtesy",x=-595,y=228,cost=1,parents={"c_peer2","x_appraisal"},anyParent=true,title="Read Their Measure",short="+1 /eye",description="",effectText="+1 Renown from scored /eye cards.",effect={statBonus={eye=1}},icon=RPAL.ICONS.eyeFocus},
    c_readroom = {
        id="c_readroom",type="major",branch="courtesy",x=-675,y=205,cost=2,parents={"c_peer2","c_eye1"},anyParent=true,
        title="Read the Room",short="SCRUTINY",description="",
        effectText="/peer and /eye gain +1 Renown. If two different characters /peer and /eye you within 7 sec, gain +6 Renown.",
        detailText="Passive: scored /peer and /eye each gain +1 Renown. Proc: a /peer and an /eye may arrive in either order, but must come from two different characters within 7 seconds. Completing the pair awards +6 bonus Renown.",
        effect={statBonus={peer=1,eye=1},scrutinyChain=true},icon=RPAL.ICONS.eye,
    },
    c_nod3 = {id="c_nod3",type="minor",branch="courtesy",x=-690,y=302,cost=1,parents={"c_readroom","c_grace"},anyParent=true,title="The Smallest Assent",short="+1 /nod",description="",effectText="+1 Renown from scored /nod or /yes cards.",effect={statBonus={nod=1}},icon=RPAL.ICONS.scroll},
    c_shy2 = {id="c_shy2",type="minor",branch="courtesy",x=-748,y=355,cost=1,parents={"c_nod3","c_shy"},anyParent=true,title="Eyes Lowered",short="+1 /shy",description="",effectText="+1 Renown from scored /shy cards.",effect={statBonus={shy=1}},icon=RPAL.ICONS.scroll},

    -- FELLOWSHIP WEAVE -----------------------------------------------------
    f_nod1 = {id="f_nod1",type="minor",branch="fellowship",x=335,y=88,cost=1,parents={"f_pet","x_acknowledge"},anyParent=true,title="Nod Among Friends",short="+1 /nod",description="",effectText="+1 Renown from scored /nod or /yes cards.",effect={statBonus={nod=1}},icon=RPAL.ICONS.scroll},
    f_pet2 = {id="f_pet2",type="minor",branch="fellowship",x=445,y=130,cost=1,parents={"f_nod1","x_affection"},anyParent=true,title="Ruffle of the Hair",short="+1 /pet",description="",effectText="+1 Renown from scored /pet head-pat cards.",effect={statBonus={pet=1}},icon=RPAL.ICONS.emoteHeart},
    f_smile2 = {id="f_smile2",type="minor",branch="fellowship",x=535,y=175,cost=1,parents={"f_pet2","x_warm_regard"},anyParent=true,title="Smile Back",short="+1 /smile",description="",effectText="+1 Renown from scored /smile cards.",effect={statBonus={smile=1}},icon=RPAL.ICONS.emoteHeart},
    f_warmth = {
        id="f_warmth",type="major",branch="fellowship",x=585,y=105,cost=2,parents={"f_pet2","f_smile2"},anyParent=true,
        title="Warmth After a Headpat",short="HEADPAT → SMILE",description="",
        effectText="If the same character /pet headpats you and then /smiles within 8 sec, gain +5 Renown.",
        detailText="Trigger window: 8 seconds. One character must first use /pet toward you (the affectionate head-pat emote). If that same character then /smiles at you before the window expires, gain +5 bonus Renown.",
        effect={affectionateSequence=true},icon=RPAL.ICONS.emoteHeart,
    },
    f_peer2 = {id="f_peer2",type="minor",branch="fellowship",x=625,y=235,cost=1,parents={"f_smile2","x_attention"},anyParent=true,title="Recognized in Company",short="+1 /peer",description="",effectText="+1 Renown from scored /peer cards.",effect={statBonus={peer=1}},icon=RPAL.ICONS.magnify},
    f_pet3 = {id="f_pet3",type="minor",branch="fellowship",x=705,y=288,cost=1,parents={"f_peer2","f_laughter"},anyParent=true,title="Unceremonious Affection",short="+1 /pet",description="",effectText="+1 Renown from scored /pet head-pat cards.",effect={statBonus={pet=1}},icon=RPAL.ICONS.emoteHeart},
    f_shy1 = {id="f_shy1",type="minor",branch="fellowship",x=760,y=335,cost=1,parents={"f_pet3","c_shy2"},anyParent=true,title="A Bashful Welcome",short="+1 /shy",description="",effectText="+1 Renown from scored /shy cards.",effect={statBonus={shy=1}},icon=RPAL.ICONS.scroll},

    -- CHRONICLE WEAVE ------------------------------------------------------
    l_peer1 = {id="l_peer1",type="minor",branch="chronicle",x=-215,y=-165,cost=1,parents={"l_rude","x_attention"},anyParent=true,title="Watched Closely",short="+1 /peer",description="",effectText="+1 Renown from scored /peer cards.",effect={statBonus={peer=1}},icon=RPAL.ICONS.magnify},
    l_eye1 = {id="l_eye1",type="minor",branch="chronicle",x=-315,y=-235,cost=1,parents={"l_peer1","x_appraisal"},anyParent=true,title="Under Suspicion",short="+1 /eye",description="",effectText="+1 Renown from scored /eye cards.",effect={statBonus={eye=1}},icon=RPAL.ICONS.eyeFocus},
    l_frown2 = {id="l_frown2",type="minor",branch="chronicle",x=-405,y=-320,cost=1,parents={"l_eye1","l_edge"},anyParent=true,title="Disapproval Lingers",short="+1 /frown",description="",effectText="+1 Renown from scored /frown cards.",effect={statBonus={frown=1}},icon=RPAL.ICONS.emoteFrown},
    l_snicker2 = {id="l_snicker2",type="minor",branch="chronicle",x=215,y=-175,cost=1,parents={"l_snicker","x_banter"},anyParent=true,title="Private Amusement",short="+1 /snicker",description="",effectText="+1 Renown from scored /snicker cards.",effect={statBonus={snicker=1}},icon=RPAL.ICONS.skull},
    l_smirk2 = {id="l_smirk2",type="minor",branch="chronicle",x=315,y=-240,cost=1,parents={"l_snicker2","x_appraisal"},anyParent=true,title="Crooked Expression",short="+1 /smirk",description="",effectText="+1 Renown from scored /smirk cards.",effect={statBonus={smirk=1}},icon=RPAL.ICONS.scroll},
    l_murmur = {
        id="l_murmur",type="major",branch="chronicle",x=350,y=-365,cost=2,parents={"l_snicker2","l_smirk2"},anyParent=true,
        title="Whispered Amusement",short="SNICKER → SMIRK",description="",
        effectText="/snicker and /smirk gain +1 Renown. Different characters landing them within 6 sec grants +5 Renown.",
        detailText="Passive: scored /snicker and /smirk each gain +1 Renown. Proc: a /snicker and a /smirk may occur in either order, but must come from two different characters within 6 seconds to award +5 bonus Renown.",
        effect={statBonus={snicker=1,smirk=1},mischiefEcho=true},icon=RPAL.ICONS.book,
    },
    l_snicker3 = {id="l_snicker3",type="minor",branch="chronicle",x=430,y=-445,cost=1,parents={"l_murmur","l_edge"},anyParent=true,title="Suppressed Laughter",short="+1 /snicker",description="",effectText="+1 Renown from scored /snicker cards.",effect={statBonus={snicker=1}},icon=RPAL.ICONS.skull},
    l_smirk3 = {id="l_smirk3",type="minor",branch="chronicle",x=505,y=-515,cost=1,parents={"l_snicker3","l_storybeat"},anyParent=true,title="The Look That Knows",short="+1 /smirk",description="",effectText="+1 Renown from scored /smirk cards.",effect={statBonus={smirk=1}},icon=RPAL.ICONS.scroll},
    l_nod1 = {id="l_nod1",type="minor",branch="chronicle",x=0,y=-300,cost=1,parents={"x_appraisal","x_banter"},anyParent=true,title="A Grave Nod",short="+1 /nod",description="",effectText="+1 Renown from scored /nod or /yes cards.",effect={statBonus={nod=1}},icon=RPAL.ICONS.scroll},
    l_consensus = {
        id="l_consensus",type="major",branch="chronicle",x=-95,y=-430,cost=2,parents={"l_nod1","l_edge"},anyParent=true,
        title="The Room Agrees",short="EVERY 4 NODS",description="",
        effectText="Every 4th successfully scored /nod or /yes grants +4 bonus Renown.",
        detailText="Persistent counter. Only successfully scored /nod and /yes interactions advance it. Every fourth one awards +4 bonus Renown, then the counter continues toward the next fourth nod.",
        effect={nodCadence={every=4,bonus=4}},icon=RPAL.ICONS.replay,
    },

    -- POINT / SNIFF EXTENSION ------------------------------------------------
    -- These are intentionally simple starter nodes. More nodes can be added
    -- later without changing the talent engine or tooltip feature.
    c_point = {
        id="c_point",type="minor",branch="courtesy",x=-825,y=430,cost=1,parents={"c_shy2","c_threefold"},anyParent=true,
        title="A Point Made",short="+1 /point",description="",
        effectText="+1 Renown from scored /point cards.",
        effect={statBonus={point=1}},icon=RPAL.ICONS.eyeFocus,
    },
    c_point_major = {
        id="c_point_major",type="major",branch="courtesy",x=-900,y=525,cost=2,parents={"c_point","c_threefold"},anyParent=true,
        title="Direct Attention",short="POINTED NOTICE",description="",
        effectText="/point gains +1 Renown. /point and /peer each gain +1 additional Renown.",
        detailText="Passive. Successfully scored /point interactions gain an additional +1 Renown, and /peer interactions also gain +1 while this node is active.",
        effect={statBonus={point=1,peer=1}},icon=RPAL.ICONS.magnify,
    },
    l_sniff = {
        id="l_sniff",type="minor",branch="chronicle",x=585,y=-515,cost=1,parents={"l_smirk3","l_storybeat"},anyParent=true,
        title="A Suspicious Scent",short="+1 /sniff",description="",
        effectText="+1 Renown from scored /sniff cards.",
        effect={statBonus={sniff=1}},icon=RPAL.ICONS.eyeFocus,
    },
    l_sniff_major = {
        id="l_sniff_major",type="major",branch="chronicle",x=665,y=-590,cost=2,parents={"l_sniff","l_storybeat"},anyParent=true,
        title="Follow the Scent",short="KEEN SENSES",description="",
        effectText="/sniff gains +1 Renown. /sniff and /eye each gain +1 additional Renown.",
        detailText="Passive. Successfully scored /sniff interactions gain an additional +1 Renown, and /eye interactions also gain +1 while this node is active.",
        effect={statBonus={sniff=1,eye=1}},icon=RPAL.ICONS.eye,
    },

    -- MUTUAL / RELATIONSHIP CONSTELLATIONS --------------------------------
    -- These nodes deliberately bridge the three early paths. They only proc
    -- when the addon can observe BOTH directions of a targeted emote exchange.
    x_mutual_smile = {
        id="x_mutual_smile",type="major",branch="fellowship",x=0,y=345,cost=2,parents={"x_acknowledge","x_attention"},anyParent=true,
        title="A Smile Returned",short="MUTUAL /smile",description="",
        effectText="You and the same character /smile at each other within 5 sec: +7 Renown.",
        detailText="Mutual trigger. One side must target the other with /smile, then the other side must answer with a targeted /smile within 5 seconds. Either direction may happen first. Awards +7 Renown.",
        effect={mutualSmile=true},icon=RPAL.ICONS.emoteHeart,
    },
    x_mutual_nod = {
        id="x_mutual_nod",type="major",branch="courtesy",x=-205,y=286,cost=2,parents={"c_nod2","x_acknowledge"},anyParent=true,
        title="Silent Agreement",short="MUTUAL /nod",description="",
        effectText="You and the same character /nod at each other within 5 sec: +5 Renown.",
        detailText="Mutual trigger. A targeted /nod from either side must be answered by the other character's targeted /nod within 5 seconds. Awards +5 Renown.",
        effect={mutualNod=true},icon=RPAL.ICONS.scroll,
    },
    x_mutual_pet = {
        id="x_mutual_pet",type="major",branch="fellowship",x=395,y=42,cost=2,parents={"x_affection","f_pet2"},anyParent=true,
        title="Affection Answered",short="MUTUAL /pet",description="",
        effectText="You and the same character /pet each other within 6 sec: +8 Renown.",
        detailText="Mutual trigger. /pet means the affectionate head-pat emote. One character must target the other with /pet and receive a targeted /pet back within 6 seconds. Awards +8 Renown.",
        effect={mutualPet=true},icon=RPAL.ICONS.emoteHeart,
    },
    x_mutual_regard = {
        id="x_mutual_regard",type="major",branch="chronicle",x=-360,y=-92,cost=2,parents={"x_appraisal","l_eye1"},anyParent=true,
        title="Measured in Return",short="MUTUAL REGARD",description="",
        effectText="Exchange /peer or /eye with the same character within 6 sec: +8 Renown.",
        detailText="Mutual trigger. You may use /peer or /eye and the other character may answer with either /peer or /eye. The two targeted emotes must happen in opposite directions within 6 seconds. Awards +8 Renown.",
        effect={mutualRegard=true},icon=RPAL.ICONS.eyeFocus,
    },
    x_mutual_snicker = {
        id="x_mutual_snicker",type="major",branch="chronicle",x=245,y=-330,cost=2,parents={"x_banter","l_snicker2"},anyParent=true,
        title="Conspiratorial Snicker",short="MUTUAL /snicker",description="",
        effectText="You and the same character /snicker at each other within 5 sec: +7 Renown.",
        detailText="Mutual trigger. A targeted /snicker from either side must be answered by the other character's targeted /snicker within 5 seconds. Awards +7 Renown.",
        effect={mutualSnicker=true},icon=RPAL.ICONS.skull,
    },
    x_mutual_smirk = {
        id="x_mutual_smirk",type="major",branch="chronicle",x=410,y=-150,cost=2,parents={"x_banter","l_smirk2"},anyParent=true,
        title="Knowing Looks",short="MUTUAL /smirk",description="",
        effectText="You and the same character /smirk at each other within 5 sec: +6 Renown.",
        detailText="Mutual trigger. One targeted /smirk must be answered by a targeted /smirk from the same character within 5 seconds. Awards +6 Renown.",
        effect={mutualSmirk=true},icon=RPAL.ICONS.scroll,
    },
    x_softened_guard = {
        id="x_softened_guard",type="major",branch="courtesy",x=-520,y=-425,cost=2,parents={"l_frown2","c_smile2"},anyParent=true,
        title="A Guard Softened",short="FROWN ↔ SMILE",description="",
        effectText="Exchange a targeted /frown and /smile with the same character within 6 sec: +8 Renown.",
        detailText="Mutual trigger. One side must use /frown and the other side /smile, in either order, targeting each other within 6 seconds. Awards +8 Renown.",
        effect={mutualMood=true},icon=RPAL.ICONS.refresh,
    },
}


-- ============================================================
-- Atlas build expansion v4
-- The original Atlas grew organically into three long spokes.  v4 keeps all
-- existing node IDs (so old characters remain valid) but reflows the Atlas
-- into concentric paths with real cross-tree junctions and build keystones.
-- ============================================================
RPAL.TALENT_BRANCHES.confluence = {
    name = "The Confluence",
    short = "CONFLUENCE",
    description = "Junctions where two paths meet. These talents reward hybrid builds instead of a single straight branch.",
    color = {0.92,0.61,0.92},
}

local TN=RPAL.TALENT_NODES

-- Courtesy build nodes ----------------------------------------------------
TN.c_etiquette = {
    id="c_etiquette",type="minor",branch="courtesy",cost=1,parents={"c_smile2","c_grace"},anyParent=true,
    title="Practised Etiquette",short="WAVE + NOD",description="",
    effectText="+1 Renown from /wave and /nod or /yes cards.",
    effect={statBonus={wave=1,nod=1}},icon=RPAL.ICONS.scroll,
}
TN.c_presence = {
    id="c_presence",type="minor",branch="courtesy",cost=1,parents={"c_readroom","c_peer"},anyParent=true,
    title="Commanding Presence",short="PEER + EYE",description="",
    effectText="+1 Renown from /peer and /eye cards.",
    effect={statBonus={peer=1,eye=1}},icon=RPAL.ICONS.eyeFocus,
}
TN.c_discretion = {
    id="c_discretion",type="minor",branch="courtesy",cost=1,parents={"c_shy2","c_presence"},anyParent=true,
    title="Quiet Discretion",short="SHY + SNIFF",description="",
    effectText="+1 Renown from /shy and /sniff cards.",
    effect={statBonus={shy=1,sniff=1}},icon=RPAL.ICONS.scroll,
}
TN.c_hospitality = {
    id="c_hospitality",type="minor",branch="courtesy",cost=1,parents={"c_cheer","c_etiquette"},anyParent=true,
    title="The Good Host",short="CHEER + SMILE",description="",
    effectText="+1 Renown from /cheer and /smile cards.",
    effect={statBonus={cheer=1,smile=1}},icon=RPAL.ICONS.trophy,
}
TN.c_envoy = {
    id="c_envoy",type="keystone",branch="courtesy",cost=3,parents={"c_etiquette","c_hospitality"},anyParent=true,
    requiresBranchSpent={courtesy=6},exclusiveGroup="courtesy_mastery",
    title="The Envoy",short="ENVOY",description="A build keystone focused on introductions, grace and public warmth.",
    effectText="Characters Met grants +2 Renown. /wave, /nod, /smile and /cheer each gain +1 Renown.",
    detailText="Courtesy Mastery. Only one Courtesy mastery may be learned at a time. Requires 6 points already spent in Courtesy.",
    effect={characterMetBonus=2,statBonus={wave=1,nod=1,smile=1,cheer=1}},icon=RPAL.ICONS.people,
}
TN.c_observer = {
    id="c_observer",type="keystone",branch="courtesy",cost=3,parents={"c_presence","c_discretion"},anyParent=true,
    requiresBranchSpent={courtesy=6},exclusiveGroup="courtesy_mastery",
    title="The Observer",short="OBSERVER",description="A build keystone focused on reading a room rather than owning it.",
    effectText="/peer, /eye, /sniff and /point each gain +2 Renown.",
    detailText="Courtesy Mastery. Only one Courtesy mastery may be learned at a time. Requires 6 points already spent in Courtesy.",
    effect={statBonus={peer=2,eye=2,sniff=2,point=2}},icon=RPAL.ICONS.magnify,
}
TN.c_ambassador = {
    id="c_ambassador",type="major",branch="courtesy",cost=3,parents={"c_envoy"},
    requiresBranchSpent={courtesy=10},requiresTalent="c_envoy",
    title="Ambassador's Rhythm",short="5 FRIENDLY",description="",
    effectText="Every 5 scored /wave, /nod, /smile or /cheer interactions grants +6 bonus Renown.",
    detailText="Build payoff for the Envoy route. The cadence persists across sessions and only counts scored interactions.",
    effect={cadence={id="ambassador",keys={wave=true,nod=true,smile=true,cheer=true},every=5,bonus=6,title="Ambassador's Rhythm"}},icon=RPAL.ICONS.star,
}
TN.c_examiner = {
    id="c_examiner",type="major",branch="courtesy",cost=3,parents={"c_observer"},
    requiresBranchSpent={courtesy=10},requiresTalent="c_observer",
    title="Nothing Escapes Notice",short="4 OBSERVE",description="",
    effectText="Every 4 scored /peer, /eye, /sniff or /point interactions grants +7 bonus Renown.",
    detailText="Build payoff for the Observer route. The cadence persists across sessions and rewards deliberate observation play.",
    effect={cadence={id="examiner",keys={peer=true,eye=true,sniff=true,point=true},every=4,bonus=7,title="Nothing Escapes Notice"}},icon=RPAL.ICONS.eyeFocus,
}

-- Fellowship build nodes -------------------------------------------------
TN.f_intro2 = {
    id="f_intro2",type="minor",branch="fellowship",cost=1,parents={"f_met2","f_peer2"},anyParent=true,
    title="Another Name Learned",short="+1 MEET",description="",
    effectText="Characters Met awards +1 additional Renown.",effect={characterMetBonus=1},icon=RPAL.ICONS.people,
}
TN.f_merriment = {
    id="f_merriment",type="minor",branch="fellowship",cost=1,parents={"f_laughter","f_cheer"},anyParent=true,
    title="Merriment",short="DANCE + CHEER",description="",
    effectText="+1 Renown from /dance and /cheer cards.",effect={statBonus={dance=1,cheer=1}},icon=RPAL.ICONS.music,
}
TN.f_companion = {
    id="f_companion",type="minor",branch="fellowship",cost=1,parents={"f_pet3","f_shy1"},anyParent=true,
    title="Easy Familiarity",short="PET + SHY",description="",
    effectText="+1 Renown from /pet and /shy cards.",effect={statBonus={pet=1,shy=1}},icon=RPAL.ICONS.emoteHeart,
}
TN.f_hearthside = {
    id="f_hearthside",type="minor",branch="fellowship",cost=1,parents={"f_smile2","f_companion"},anyParent=true,
    title="Hearthside Company",short="SMILE + PEER",description="",
    effectText="+1 Renown from /smile and /peer cards.",effect={statBonus={smile=1,peer=1}},icon=RPAL.ICONS.book,
}
TN.f_host = {
    id="f_host",type="keystone",branch="fellowship",cost=3,parents={"f_merriment","f_intro2"},anyParent=true,
    requiresBranchSpent={fellowship=6},exclusiveGroup="fellowship_mastery",
    title="The Host",short="HOST",description="A build keystone for crowds, celebrations and bringing people together.",
    effectText="Characters Met grants +1 Renown. /cheer and /dance gain +2 Renown; /wave gains +1.",
    detailText="Fellowship Mastery. Only one Fellowship mastery may be learned at a time. Requires 6 points already spent in Fellowship.",
    effect={characterMetBonus=1,statBonus={cheer=2,dance=2,wave=1}},icon=RPAL.ICONS.people,
}
TN.f_confidant = {
    id="f_confidant",type="keystone",branch="fellowship",cost=3,parents={"f_companion","f_hearthside"},anyParent=true,
    requiresBranchSpent={fellowship=6},exclusiveGroup="fellowship_mastery",
    title="The Confidant",short="CONFIDANT",description="A build keystone for quieter bonds and familiar company.",
    effectText="/pet, /smile, /shy and /peer each gain +2 Renown.",
    detailText="Fellowship Mastery. Only one Fellowship mastery may be learned at a time. Requires 6 points already spent in Fellowship.",
    effect={statBonus={pet=2,smile=2,shy=2,peer=2}},icon=RPAL.ICONS.emoteHeart,
}
TN.f_toastmaster = {
    id="f_toastmaster",type="major",branch="fellowship",cost=3,parents={"f_host"},requiresBranchSpent={fellowship=10},requiresTalent="f_host",
    title="Toastmaster",short="5 REVELRY",description="",
    effectText="Every 5 scored /cheer, /dance, /wave or /smile interactions grants +6 bonus Renown.",
    detailText="Build payoff for the Host route. The cadence persists across sessions.",
    effect={cadence={id="toastmaster",keys={cheer=true,dance=true,wave=true,smile=true},every=5,bonus=6,title="Toastmaster"}},icon=RPAL.ICONS.trophy,
}
TN.f_anchor = {
    id="f_anchor",type="major",branch="fellowship",cost=3,parents={"f_confidant"},requiresBranchSpent={fellowship=10},requiresTalent="f_confidant",
    title="An Anchor in Company",short="4 FAMILIAR",description="",
    effectText="Every 4 scored /pet, /smile, /shy or /peer interactions grants +6 bonus Renown.",
    detailText="Build payoff for the Confidant route. The cadence persists across sessions.",
    effect={cadence={id="anchor",keys={pet=true,smile=true,shy=true,peer=true},every=4,bonus=6,title="An Anchor in Company"}},icon=RPAL.ICONS.emoteHeart,
}

-- Chronicle build nodes --------------------------------------------------
TN.l_rude2 = {
    id="l_rude2",type="minor",branch="chronicle",cost=1,parents={"l_edge","l_murmur"},anyParent=true,
    title="Barbed Reply",short="RUDE + SMIRK",description="",
    effectText="+1 Renown from /rude and /smirk cards.",effect={statBonus={rude=1,smirk=1}},icon=RPAL.ICONS.skull,
}
TN.l_witnessing = {
    id="l_witnessing",type="minor",branch="chronicle",cost=1,parents={"l_eye1","l_consensus"},anyParent=true,
    title="Bearing Witness",short="PEER + EYE",description="",
    effectText="+1 Renown from /peer and /eye cards.",effect={statBonus={peer=1,eye=1}},icon=RPAL.ICONS.eyeFocus,
}
TN.l_restraint = {
    id="l_restraint",type="minor",branch="chronicle",cost=1,parents={"l_no","l_frown2"},anyParent=true,
    title="Measured Restraint",short="NO + FROWN",description="",
    effectText="+1 Renown from /no and /frown cards.",effect={statBonus={no=1,frown=1}},icon=RPAL.ICONS.lock,
}
TN.l_grief = {
    id="l_grief",type="minor",branch="chronicle",cost=1,parents={"l_cry","l_restraint"},anyParent=true,
    title="Weight of the Scene",short="CRY + FROWN",description="",
    effectText="+1 Renown from /cry and /frown cards.",effect={statBonus={cry=1,frown=1}},icon=RPAL.ICONS.emoteFrown,
}
TN.l_provocateur = {
    id="l_provocateur",type="keystone",branch="chronicle",cost=3,parents={"l_rude2","l_murmur"},anyParent=true,
    requiresBranchSpent={chronicle=6},exclusiveGroup="chronicle_mastery",
    title="The Provocateur",short="PROVOCATEUR",description="A build keystone for antagonism, barbs and dangerous amusement.",
    effectText="/rude, /smirk and /snicker each gain +2 Renown; /no gains +1.",
    detailText="Chronicle Mastery. Only one Chronicle mastery may be learned at a time. Requires 6 points already spent in Chronicle.",
    effect={statBonus={rude=2,smirk=2,snicker=2,no=1}},icon=RPAL.ICONS.skull,
}
TN.l_witness = {
    id="l_witness",type="keystone",branch="chronicle",cost=3,parents={"l_witnessing","l_grief"},anyParent=true,
    requiresBranchSpent={chronicle=6},exclusiveGroup="chronicle_mastery",
    title="The Witness",short="WITNESS",description="A build keystone for remembering tension, sorrow and the details others miss.",
    effectText="/peer, /eye, /cry and /frown each gain +2 Renown.",
    detailText="Chronicle Mastery. Only one Chronicle mastery may be learned at a time. Requires 6 points already spent in Chronicle.",
    effect={statBonus={peer=2,eye=2,cry=2,frown=2}},icon=RPAL.ICONS.book,
}
TN.l_instigator = {
    id="l_instigator",type="major",branch="chronicle",cost=3,parents={"l_provocateur"},requiresBranchSpent={chronicle=10},requiresTalent="l_provocateur",
    title="The Spark in the Powder",short="4 BARBS",description="",
    effectText="Every 4 scored /rude, /smirk, /snicker or /no interactions grants +7 bonus Renown.",
    detailText="Build payoff for the Provocateur route. The cadence persists across sessions.",
    effect={cadence={id="instigator",keys={rude=true,smirk=true,snicker=true,no=true},every=4,bonus=7,title="The Spark in the Powder"}},icon=RPAL.ICONS.warning,
}
TN.l_archivist = {
    id="l_archivist",type="major",branch="chronicle",cost=3,parents={"l_witness"},requiresBranchSpent={chronicle=10},requiresTalent="l_witness",
    title="The Living Archive",short="5 WITNESS",description="",
    effectText="Every 5 scored /peer, /eye, /cry, /frown or /nod interactions grants +8 bonus Renown.",
    detailText="Build payoff for the Witness route. The cadence persists across sessions.",
    effect={cadence={id="archivist",keys={peer=true,eye=true,cry=true,frown=true,nod=true},every=5,bonus=8,title="The Living Archive"}},icon=RPAL.ICONS.book,
}

-- Confluence nodes: real cross-tree junctions ----------------------------
TN.x_diplomat = {
    id="x_diplomat",type="major",branch="confluence",cost=2,parents={"c_grace","f_laughter"},
    requiresBranchSpent={courtesy=4,fellowship=4},
    title="Diplomat's Table",short="COURT + FELLOWS",description="",
    effectText="Characters Met grants +1 Renown. /wave, /smile and /cheer gain +1 Renown.",
    detailText="Confluence. Requires investment in both Courtesy and Fellowship; both connected parent nodes must be learned.",
    effect={characterMetBonus=1,statBonus={wave=1,smile=1,cheer=1}},icon=RPAL.ICONS.people,
}
TN.x_confidant_bridge = {
    id="x_confidant_bridge",type="major",branch="confluence",cost=2,parents={"f_warmth","l_murmur"},
    requiresBranchSpent={fellowship=4,chronicle=4},
    title="Confidences After Midnight",short="FELLOW + CHRON",description="",
    effectText="/pet, /peer, /smirk and /shy gain +1 Renown.",
    detailText="Confluence. Requires investment in both Fellowship and Chronicle.",
    effect={statBonus={pet=1,peer=1,smirk=1,shy=1}},icon=RPAL.ICONS.book,
}
TN.x_counterpoint = {
    id="x_counterpoint",type="major",branch="confluence",cost=2,parents={"l_consensus","c_readroom"},
    requiresBranchSpent={chronicle=4,courtesy=4},
    title="Counterpoint",short="CHRON + COURT",description="",
    effectText="/nod, /eye, /frown and /smile gain +1 Renown.",
    detailText="Confluence. Requires investment in both Chronicle and Courtesy.",
    effect={statBonus={nod=1,eye=1,frown=1,smile=1}},icon=RPAL.ICONS.refresh,
}
TN.x_storyweaver = {
    id="x_storyweaver",type="keystone",branch="confluence",cost=3,parents={"x_diplomat","x_confidant_bridge"},anyParent=true,
    requiresTotalSpent=16,
    title="Storyweaver",short="HYBRID BUILD",description="A late hybrid keystone for players who deliberately split points across paths.",
    effectText="Every 6 scored social interactions of any tracked kind grants +7 bonus Renown.",
    detailText="Confluence Keystone. Requires 16 total points spent before it can be learned.",
    effect={cadence={id="storyweaver",all=true,every=6,bonus=7,title="Storyweaver"}},icon=RPAL.ICONS.star,
}
TN.x_social_weave = {
    id="x_social_weave",type="minor",branch="confluence",cost=1,parents={"x_diplomat","x_counterpoint"},anyParent=true,
    title="Social Weave",short="MEET + PEER",description="",
    effectText="Characters Met grants +1 Renown and /peer gains +1 Renown.",
    effect={characterMetBonus=1,statBonus={peer=1}},icon=RPAL.ICONS.people,
}
TN.x_double_read = {
    id="x_double_read",type="minor",branch="confluence",cost=1,parents={"x_confidant_bridge","x_counterpoint"},anyParent=true,
    title="Two Sides of the Same Room",short="EYE + SMIRK",description="",
    effectText="+1 Renown from /eye and /smirk cards.",effect={statBonus={eye=1,smirk=1}},icon=RPAL.ICONS.eyeFocus,
}


RPAL.TALENT_NODE_ORDER = {
    "origin",
    -- interwoven opening ring
    "c_nod","f_met1","l_smirk","x_acknowledge","x_attention","x_warm_regard","x_appraisal","x_banter","x_affection",
    "x_mutual_smile","x_mutual_nod","x_mutual_pet","x_mutual_regard","x_mutual_snicker","x_mutual_smirk","x_softened_guard",
    -- courtesy
    "c_wave","c_nod2","c_smile","c_peer2","c_smile2","c_eye1","c_readroom","c_grace","c_nod3","c_peer","c_shy","c_shy2","c_threefold","c_point","c_point_major","c_royal","c_cheer","c_luminous","c_aura","c_memoir1","c_memoir2",
    -- fellowship
    "f_pet","f_nod1","f_pet2","f_smile2","f_warmth","f_dance","f_laughter","f_peer2","f_pet3","f_cheer","f_shy1","f_met2","f_crowd","f_dream","f_peer","f_welcome","f_frame","f_memoir1","f_memoir2",
    -- chronicle
    "l_rude","l_snicker","l_peer1","l_eye1","l_snicker2","l_smirk2","l_nod1","l_edge","l_consensus","l_murmur","l_frown2","l_snicker3","l_smirk3","l_sniff","l_sniff_major","l_cry","l_frown","l_storybeat","l_no","l_contrast","l_ornament","l_atmosphere","l_crest","l_memoir1","l_memoir2",

}

local TALENT_V4_EXTRA_ORDER={
    "c_etiquette","c_presence","c_discretion","c_hospitality","c_envoy","c_observer","c_ambassador","c_examiner",
    "f_intro2","f_merriment","f_companion","f_hearthside","f_host","f_confidant","f_toastmaster","f_anchor",
    "l_rude2","l_witnessing","l_restraint","l_grief","l_provocateur","l_witness","l_instigator","l_archivist",
    "x_diplomat","x_confidant_bridge","x_counterpoint","x_social_weave","x_double_read","x_storyweaver",
}
for _,id in ipairs(TALENT_V4_EXTRA_ORDER) do
    RPAL.TALENT_NODE_ORDER[#RPAL.TALENT_NODE_ORDER+1]=id
end

-- Concentric Atlas layout -------------------------------------------------
local function TalentPolar(id,radius,degrees)
    local node=TN[id]
    if not node then return end
    local rad=math.rad(degrees)
    node.x=math.floor(math.cos(rad)*radius+0.5)
    node.y=math.floor(math.sin(rad)*radius+0.5)
end
local function TalentArc(ids,radius,startDeg,endDeg)
    local count=#ids
    if count==0 then return end
    for index,id in ipairs(ids) do
        local t=(count==1) and 0.5 or ((index-1)/(count-1))
        local deg=startDeg+((endDeg-startDeg)*t)
        TalentPolar(id,radius,deg)
    end
end

TN.origin.x,TN.origin.y=0,0
-- The opening ring is intentionally shared.  These nodes rotate around the
-- origin before the three paths peel away, creating visible route choices.
TalentArc({"c_nod","x_acknowledge","x_mutual_nod","x_mutual_smile","x_attention","f_met1","x_warm_regard","x_affection","f_pet","x_banter","l_smirk","x_appraisal","l_rude","x_mutual_regard"},245,165,-165)

-- Courtesy occupies the upper-left arc and bends around the Atlas rather
-- than continuing as a straight diagonal spoke.
TalentArc({"c_wave","c_nod2","c_smile","c_peer2","c_smile2","c_eye1","c_readroom","c_grace"},470,112,190)
TalentArc({"c_nod3","c_peer","c_shy","c_shy2","c_etiquette","c_presence","c_discretion","c_hospitality","c_threefold","c_point","c_point_major","c_cheer","c_luminous"},720,104,194)
TalentArc({"c_envoy","c_ambassador","c_royal","c_memoir2","c_observer","c_examiner","c_aura","c_memoir1"},1010,110,205)

-- Fellowship occupies the upper-right arc.
TalentArc({"f_nod1","f_pet2","f_smile2","f_warmth","f_dance","f_laughter","f_peer2","f_pet3"},470,-5,72)
TalentArc({"f_cheer","f_shy1","f_intro2","f_merriment","f_companion","f_hearthside","f_met2","f_crowd","f_peer","f_welcome"},720,-8,80)
TalentArc({"f_host","f_toastmaster","f_dream","f_memoir2","f_confidant","f_anchor","f_frame","f_memoir1"},1010,-18,76)

-- Chronicle wraps the entire lower arc; the two subroutes intentionally
-- curve toward Courtesy and Fellowship again near the outside.
TalentArc({"l_peer1","l_eye1","l_frown2","l_nod1","l_edge","l_consensus","l_snicker","l_snicker2","l_smirk2","l_murmur"},470,215,335)
TalentArc({"l_cry","l_frown","l_restraint","l_grief","l_witnessing","l_storybeat","l_no","l_contrast","l_rude2","l_snicker3","l_smirk3","l_sniff","l_sniff_major"},720,216,330)
TalentArc({"l_witness","l_archivist","l_ornament","l_memoir1","l_crest","l_memoir2","l_atmosphere","l_provocateur","l_instigator"},1010,216,324)

-- Mutual talents and explicit Confluences sit at the seams between arcs.
TalentPolar("x_mutual_pet",610,4)
TalentPolar("x_mutual_snicker",560,318)
TalentPolar("x_mutual_smirk",610,340)
TalentPolar("x_softened_guard",610,218)
TalentPolar("x_mutual_regard",560,198)
TalentPolar("x_diplomat",790,90)
TalentPolar("x_confidant_bridge",790,342)
TalentPolar("x_counterpoint",790,198)
-- Downstream confluence talents sit close to the junction that feeds them.
-- This keeps the Atlas readable instead of throwing long diagonal lines across
-- the entire field.
TalentPolar("x_social_weave",945,96)
TalentPolar("x_double_read",930,338)
TalentPolar("x_storyweaver",1110,92)

-- PoE-style sparse path graph ------------------------------------------------
-- The old Atlas inherited a large number of historical parent links.  Once
-- those nodes were moved onto rings, the links crossed half the screen and the
-- result looked like a tactical map.  Re-author the active path graph from the
-- final node positions: each branch is a nearest-neighbour tree rooted at the
-- origin, with only three explicit cross-tree junctions.
RPAL.TALENT_LINK_MAX_DISTANCE = 315

local function TalentDistance(a,b)
    if not a or not b then return math.huge end
    local dx=(tonumber(a.x) or 0)-(tonumber(b.x) or 0)
    local dy=(tonumber(a.y) or 0)-(tonumber(b.y) or 0)
    return math.sqrt(dx*dx+dy*dy)
end

local function TalentOrderIndex()
    local out={}
    for i,id in ipairs(RPAL.TALENT_NODE_ORDER or {}) do out[id]=i end
    return out
end

local function BuildNearestBranchTree(branch)
    local order=TalentOrderIndex()
    local connected={origin=true}
    local remaining={}
    for _,id in ipairs(RPAL.TALENT_NODE_ORDER or {}) do
        local node=TN[id]
        if node and node.branch==branch then remaining[id]=true end
    end

    while next(remaining) do
        local bestID,bestParent,bestDistance=nil,nil,math.huge
        for _,id in ipairs(RPAL.TALENT_NODE_ORDER or {}) do
            if remaining[id] then
                local node=TN[id]
                for parentID in pairs(connected) do
                    local parent=TN[parentID]
                    if parent and (parentID=="origin" or parent.branch==branch) then
                        local distance=TalentDistance(node,parent)
                        if distance<bestDistance-0.01 or (math.abs(distance-bestDistance)<=0.01 and ((order[id] or 9999)<(order[bestID] or 9999))) then
                            bestID,bestParent,bestDistance=id,parentID,distance
                        end
                    end
                end
            end
        end
        if not bestID then break end
        local node=TN[bestID]
        node.parents={bestParent}
        node.anyParent=nil
        node.visualParents={bestParent}
        node.linkDistance=bestDistance
        remaining[bestID]=nil
        connected[bestID]=true
    end
end

BuildNearestBranchTree("courtesy")
BuildNearestBranchTree("fellowship")
BuildNearestBranchTree("chronicle")

local function NearestNodeInBranch(node,branch)
    local bestID,bestDistance=nil,math.huge
    for _,id in ipairs(RPAL.TALENT_NODE_ORDER or {}) do
        local candidate=TN[id]
        if candidate and candidate.branch==branch then
            local distance=TalentDistance(node,candidate)
            if distance<bestDistance then bestID,bestDistance=id,distance end
        end
    end
    return bestID,bestDistance
end

local function SetConfluenceParents(id,branches)
    local node=TN[id]
    if not node then return end
    local parents={}
    for _,branch in ipairs(branches or {}) do
        local parentID=NearestNodeInBranch(node,branch)
        if parentID then parents[#parents+1]=parentID end
    end
    node.parents=parents
    node.anyParent=nil
    node.visualParents={}
    for _,parentID in ipairs(parents) do
        if TalentDistance(node,TN[parentID])<=RPAL.TALENT_LINK_MAX_DISTANCE then
            node.visualParents[#node.visualParents+1]=parentID
        end
    end
end

-- Only these three nodes visibly merge separate trees.
SetConfluenceParents("x_diplomat",{"courtesy","fellowship"})
SetConfluenceParents("x_confidant_bridge",{"fellowship","chronicle"})
SetConfluenceParents("x_counterpoint",{"chronicle","courtesy"})

-- The remaining hybrid talents extend locally from a junction instead of
-- drawing another set of screen-wide diagonals.
TN.x_social_weave.parents={"x_diplomat"};TN.x_social_weave.anyParent=nil;TN.x_social_weave.visualParents={"x_diplomat"}
TN.x_double_read.parents={"x_confidant_bridge"};TN.x_double_read.anyParent=nil;TN.x_double_read.visualParents={"x_confidant_bridge"}
TN.x_storyweaver.parents={"x_social_weave"};TN.x_storyweaver.anyParent=nil;TN.x_storyweaver.visualParents={"x_social_weave"}

-- Build payoffs keep their thematic keystone requirement even though the
-- sparse spatial tree may route their visible connector through a closer node.

-- v6 economy pass: major nodes are path-defining landmarks rather than
-- point sinks. They now cost one point just like ordinary path nodes;
-- build keystones retain their heavier commitment cost.
for _,node in pairs(RPAL.TALENT_NODES or {}) do
    if node and node.type=="major" then node.cost=1 end
end

RPAL.ATLAS_REWARD_LOOKUP={}

for nodeID,node in pairs(RPAL.TALENT_NODES or {}) do
    local rewards=node.rewards or (node.reward and {node.reward}) or {}
    for _,reward in ipairs(rewards) do
        if reward.kind and reward.name then
            RPAL.ATLAS_REWARD_LOOKUP[reward.kind..":"..reward.name]={node=nodeID,label="RENOWN ATLAS • "..tostring(node.title or "REWARD")}
        end
    end
end

local function EnsureTalentState()
    if not RPAL.char then return nil end
    if RPAL.SyncTRPProfileScope and not RPAL._syncingTRPProfileScope then
        RPAL._syncingTRPProfileScope=true
        RPAL.SyncTRPProfileScope(false)
        RPAL._syncingTRPProfileScope=false
    end
    RPAL.char.talents=RPAL.char.talents or {}
    local state=RPAL.char.talents
    state.version=math.max(tonumber(state.version) or 0,RPAL.TALENT_VERSION)
    state.allocated=state.allocated or {}
    state.rewards=state.rewards or {}
    state.counters=state.counters or {}
    if state.allocated.origin==nil then state.allocated.origin=0 end

    RPAL.char.memoirs=RPAL.char.memoirs or {}
    RPAL.char.memoirs.unlocked=RPAL.char.memoirs.unlocked or {}
    return state
end

function RPAL.GetTalentState()
    return EnsureTalentState()
end

function RPAL.GetTalentNode(id)
    return RPAL.TALENT_NODES and RPAL.TALENT_NODES[id]
end

function RPAL.IsTalentAllocated(id)
    local state=EnsureTalentState()
    return state and state.allocated[id]~=nil or false
end

function RPAL.GetTalentPointsForLevel(level)
    level=math.max(1,math.floor(tonumber(level) or 1))
    local interval=math.max(1,math.floor(tonumber(RPAL.TALENT_LEVEL_INTERVAL) or 2))
    local perGrant=math.max(1,math.floor(tonumber(RPAL.TALENT_POINTS_PER_LEVEL) or 1))
    return math.max(0,math.floor(level/interval)*perGrant)
end

function RPAL.GetTotalTalentPoints()
    local level=RPAL.GetCurrentRenownLevel and select(1,RPAL.GetCurrentRenownLevel()) or 1
    return RPAL.GetTalentPointsForLevel(level)
end

function RPAL.GetSpentTalentPoints()
    local state=EnsureTalentState()
    if not state then return 0 end
    local spent=0
    for id in pairs(state.allocated or {}) do
        if id~="origin" then
            local node=RPAL.TALENT_NODES[id]
            if node then spent=spent+(tonumber(node.cost) or 1) end
        end
    end
    return spent
end

function RPAL.GetAvailableTalentPoints()
    return math.max(0,RPAL.GetTotalTalentPoints()-RPAL.GetSpentTalentPoints())
end

function RPAL.GetTalentBranchSpent(branch)
    local state=EnsureTalentState()
    if not state then return 0 end
    local spent=0
    for id in pairs(state.allocated or {}) do
        if id~="origin" then
            local node=RPAL.TALENT_NODES[id]
            if node and node.branch==branch then spent=spent+(tonumber(node.cost) or 1) end
        end
    end
    return spent
end

function RPAL.GetTalentBuildKeystones()
    local state=EnsureTalentState()
    local out={}
    if not state then return out end
    for id in pairs(state.allocated or {}) do
        local node=RPAL.TALENT_NODES[id]
        if node and node.type=="keystone" then out[#out+1]=node end
    end
    table.sort(out,function(a,b) return tostring(a.title)<tostring(b.title) end)
    return out
end

function RPAL.GetTalentBuildName()
    local keys=RPAL.GetTalentBuildKeystones()
    if #keys>0 then
        local names={}
        for i=1,math.min(2,#keys) do names[#names+1]=keys[i].title end
        return table.concat(names," + ")
    end
    local c=RPAL.GetTalentBranchSpent("courtesy")
    local f=RPAL.GetTalentBranchSpent("fellowship")
    local l=RPAL.GetTalentBranchSpent("chronicle")
    if c==0 and f==0 and l==0 then return "Unshaped" end
    if c>=f and c>=l then return "Courtly Lean" end
    if f>=c and f>=l then return "Fellowship Lean" end
    return "Chronicle Lean"
end

local function ParentRequirementMet(node,state)
    local parents=node and node.parents
    if not parents or #parents==0 then return true end
    if node.anyParent then
        for _,id in ipairs(parents) do
            if state.allocated[id]~=nil then return true end
        end
        return false
    end
    for _,id in ipairs(parents) do
        if state.allocated[id]==nil then return false end
    end
    return true
end

function RPAL.CanAllocateTalent(id)
    if RPAL.IsLiteKit and RPAL.IsLiteKit() then return false,"Talents are disabled in Lite Kit." end
    local state=EnsureTalentState()
    local node=RPAL.TALENT_NODES[id]
    if not state or not node then return false,"Unknown talent." end
    if id=="origin" or state.allocated[id]~=nil then return false,"Already learned." end
    local settings=RPAL.GetSettings and RPAL.GetSettings() or {}
    if not ParentRequirementMet(node,state) then return false,"A connected node must be learned first." end
    if node.requiresBranchSpent then
        for branch,needed in pairs(node.requiresBranchSpent) do
            local have=RPAL.GetTalentBranchSpent(branch)
            if have<(tonumber(needed) or 0) then
                local def=RPAL.TALENT_BRANCHES and RPAL.TALENT_BRANCHES[branch]
                return false,"Requires "..tostring(needed).." points spent in "..tostring((def and def.name) or branch).." ("..tostring(have).."/"..tostring(needed)..")."
            end
        end
    end
    if node.requiresTotalSpent and RPAL.GetSpentTalentPoints()<(tonumber(node.requiresTotalSpent) or 0) then
        return false,"Requires "..tostring(node.requiresTotalSpent).." total Talent Points spent first."
    end
    if node.requiresTalent and state.allocated[node.requiresTalent]==nil then
        local required=RPAL.TALENT_NODES[node.requiresTalent]
        return false,"Requires "..tostring((required and required.title) or node.requiresTalent).." first."
    end
    if node.exclusiveGroup then
        for learnedID in pairs(state.allocated or {}) do
            local learned=RPAL.TALENT_NODES[learnedID]
            if learned and learned.exclusiveGroup==node.exclusiveGroup and learnedID~=id then
                return false,"Mastery already chosen: "..tostring(learned.title or learnedID)..". Respec to choose this route instead."
            end
        end
    end
    local cost=tonumber(node.cost) or 1
    if RPAL.GetAvailableTalentPoints()<cost then return false,"Not enough Talent Points." end
    return true
end

local function GrantTalentReward(node)
    if not node then return {} end
    local state=EnsureTalentState()
    if not state then return {} end

    local rewards=node.rewards or (node.reward and {node.reward}) or {}
    local granted={}
    for _,reward in ipairs(rewards) do
        if reward.kind=="theme" and reward.name and RPAL.UnlockTheme then
            local wasUnlocked=RPAL.IsThemeUnlocked and RPAL.IsThemeUnlocked(reward.name)
            RPAL.UnlockTheme(reward.name,"progression")
            state.rewards[node.id]=true
            if not wasUnlocked then granted[#granted+1]=reward end
        elseif reward.kind and reward.name and RPAL.UnlockCosmetic then
            local wasUnlocked=RPAL.IsCosmeticUnlocked and RPAL.IsCosmeticUnlocked(reward.kind,reward.name)
            RPAL.UnlockCosmetic(reward.kind,reward.name,"progression")
            state.rewards[node.id]=true
            if not wasUnlocked then granted[#granted+1]=reward end
        end
    end

    if node.memoir and RPAL.MEMOIR_DEFINITIONS[node.memoir] then
        RPAL.char.memoirs.unlocked[node.memoir]=RPAL.char.memoirs.unlocked[node.memoir] or time()
        state.rewards[node.id]=true
    end
    return granted
end

function RPAL.ApplyTalentRewards()
    if RPAL.IsLiteKit and RPAL.IsLiteKit() then return end
    local state=EnsureTalentState()
    if not state then return end
    for id in pairs(state.allocated or {}) do
        local node=RPAL.TALENT_NODES[id]
        if node then GrantTalentReward(node) end
    end
end

function RPAL.AllocateTalent(id)
    local ok,reason=RPAL.CanAllocateTalent(id)
    if not ok then return false,reason end
    local state=EnsureTalentState()
    local node=RPAL.TALENT_NODES[id]
    state.allocated[id]=time()
    local granted=GrantTalentReward(node)
    if #granted>0 and RPAL.ShowCosmeticRewardToast then
        RPAL.ShowCosmeticRewardToast(granted,"ATLAS REWARD")
    end
    if RPAL.OnTalentChanged then RPAL.OnTalentChanged(id,node) end
    return true
end

function RPAL.GetUnlockedMemoirs()
    EnsureTalentState()
    local out={}
    local unlocked=RPAL.char and RPAL.char.memoirs and RPAL.char.memoirs.unlocked or {}
    for id,at in pairs(unlocked) do
        local def=RPAL.MEMOIR_DEFINITIONS[id]
        if def then
            out[#out+1]={
                id=id,title=def.title,subtitle=def.subtitle,quote=def.quote,icon=def.icon,
                branch=def.branch,unlockedAt=tonumber(at) or 0,
            }
        end
    end
    table.sort(out,function(a,b)
        if a.unlockedAt~=b.unlockedAt then return a.unlockedAt<b.unlockedAt end
        return a.title<b.title
    end)
    return out
end

function RPAL.GetMemoirDefinition(id)
    return RPAL.MEMOIR_DEFINITIONS and RPAL.MEMOIR_DEFINITIONS[id]
end

local function ForEachAllocatedEffect(callback)
    if RPAL.IsLiteKit and RPAL.IsLiteKit() then return end
    local state=EnsureTalentState()
    if not state then return end
    for id in pairs(state.allocated or {}) do
        local node=RPAL.TALENT_NODES[id]
        if node and node.effect then callback(node.effect,node) end
    end
end

function RPAL.ModifyRenownByTalents(amount,source)
    amount=math.max(0,math.floor(tonumber(amount) or 0))
    source=tostring(source or "")
    if amount<=0 or source:find("^talent:") then return amount end

    local bonus=0
    local statKey=source:match("^interaction:(.+)$")
    ForEachAllocatedEffect(function(effect)
        if statKey and effect.statBonus then
            bonus=bonus+(tonumber(effect.statBonus[statKey]) or 0)
        elseif source=="character_met" and effect.characterMetBonus then
            bonus=bonus+(tonumber(effect.characterMetBonus) or 0)
        end
    end)
    return amount+math.max(0,math.floor(bonus))
end

function RPAL.GetTalentExtraCrests(renownAmount,source)
    renownAmount=math.max(0,math.floor(tonumber(renownAmount) or 0))
    if renownAmount<=0 then return 0 end
    local perRenown=0
    ForEachAllocatedEffect(function(effect)
        perRenown=perRenown+(tonumber(effect.crestPerRenown) or 0)
    end)
    return math.max(0,math.floor(renownAmount*perRenown))
end

local function TalentCanTriggerWith(actor)
    if RPAL.IsLiteKit and RPAL.IsLiteKit() then return false end
    if not RPAL.IsTRPInCharacter or not RPAL.IsTRPInCharacter() then return false end
    if not RPAL.IsOtherTRPInCharacter or not RPAL.IsOtherTRPInCharacter(actor) then return false end
    return true
end

local function SessionState()
    RPAL._talentSession=RPAL._talentSession or {
        laughter=nil,
        crowd={},
        greeting={},
        sorrow=nil,
        scrutiny=nil,
        warmth=nil,
        mischief=nil,
        mutual={},
        mutualCooldown={},
        lastCrowdAward=0,
        lastGreetingAward=0,
        lastContrastAward=0,
        lastScrutinyAward=0,
        lastWarmthAward=0,
        lastMischiefAward=0,
    }
    return RPAL._talentSession
end

local function HasEffectFlag(flag)
    local found=false
    ForEachAllocatedEffect(function(effect)
        if effect[flag] then found=true end
    end)
    return found
end

local function DetectMutualEmote(message)
    local lower=tostring(message or ""):lower()
    if lower:find("snicker",1,true) then return "snicker" end
    if lower:find("smirk",1,true) then return "smirk" end
    if lower:find("smile",1,true) then return "smile" end
    if lower:find("nod",1,true) then return "nod" end
    if lower:find(" pet ",1,true) or lower:find("pets ",1,true) or lower:find("pets you",1,true) or lower:find("petting",1,true) then return "pet" end
    if lower:find("peer",1,true) then return "peer" end
    if lower:find(" eye ",1,true) or lower:find("eyes",1,true) or lower:find("eying",1,true) or lower:find("eyeing",1,true) then return "eye" end
    if lower:find("frown",1,true) then return "frown" end
end

local function MutualRuleFor(keyA,keyB)
    if keyA=="smile" and keyB=="smile" and HasEffectFlag("mutualSmile") then return "A Smile Returned",7,5,"smile" end
    if keyA=="nod" and keyB=="nod" and HasEffectFlag("mutualNod") then return "Silent Agreement",5,5,"nod" end
    if keyA=="pet" and keyB=="pet" and HasEffectFlag("mutualPet") then return "Affection Answered",8,6,"pet" end
    if keyA=="snicker" and keyB=="snicker" and HasEffectFlag("mutualSnicker") then return "Conspiratorial Snicker",7,5,"snicker" end
    if keyA=="smirk" and keyB=="smirk" and HasEffectFlag("mutualSmirk") then return "Knowing Looks",6,5,"smirk" end
    if (keyA=="peer" or keyA=="eye") and (keyB=="peer" or keyB=="eye") and HasEffectFlag("mutualRegard") then return "Measured in Return",8,6,"regard" end
    if ((keyA=="frown" and keyB=="smile") or (keyA=="smile" and keyB=="frown")) and HasEffectFlag("mutualMood") then return "A Guard Softened",8,6,"mood" end
end

local function AwardTalentRenown(title,amount,source)
    local gained=RPAL.AddRenown and RPAL.AddRenown(amount,source) or 0
    gained=math.max(0,math.floor(tonumber(gained) or 0))
    if gained>0 then
        if RPAL.SceneRecordTalentProc then RPAL.SceneRecordTalentProc(title,gained) end
        if RPAL.OnTalentProc then RPAL.OnTalentProc(title,gained) end
    end
    return gained
end

local function HandleMutualEmote(direction,message,actor)
    actor=tostring(actor or "")
    if actor=="" or not TalentCanTriggerWith(actor) then return end
    local key=DetectMutualEmote(message)
    if not key then return end
    local now=GetTime and GetTime() or 0
    local session=SessionState()
    session.mutual=session.mutual or {}
    session.mutualCooldown=session.mutualCooldown or {}
    local actorKey=actor:lower()
    local previous=session.mutual[actorKey]
    if previous and previous.direction~=direction then
        local title,bonus,window,ruleKey=MutualRuleFor(previous.key,key)
        if title and (now-(previous.at or 0))<=window and (now-(session.mutualCooldown[ruleKey] or 0))>2 then
            session.mutualCooldown[ruleKey]=now
            session.mutual[actorKey]=nil
            AwardTalentRenown(title,bonus,"talent:mutual_"..tostring(ruleKey))
            return
        end
    end
    session.mutual[actorKey]={direction=direction,key=key,at=now}
end

function RPAL.HandleTalentOutgoingEmote(message,actor)
    HandleMutualEmote("out",message,actor)
end

function RPAL.HandleTalentTargetedEmote(message,actor)
    actor=tostring(actor or "")
    if actor=="" or not TalentCanTriggerWith(actor) then return end
    local lower=tostring(message or ""):lower()
    local now=GetTime and GetTime() or 0
    local session=SessionState()

    -- Mutual talent tracking sees the incoming half of a two-way exchange.
    HandleMutualEmote("in",message,actor)

    -- Two DIFFERENT characters laughing within five seconds.
    if HasEffectFlag("sharedLaughter") and
       (lower:find("laugh",1,true) or lower:find("giggl",1,true) or lower:find("chuckl",1,true)) then
        local current=session.laughter
        if current and current.actor~=actor and (now-current.at)<=5 then
            AwardTalentRenown("Shared Laughter",RPAL.CHARACTERS_MET_SCORE or 7,"talent:shared_laughter")
            session.laughter=nil
        else
            session.laughter={actor=actor,at=now}
        end
    end

    -- /peer + /eye from two different characters within seven seconds.
    if HasEffectFlag("scrutinyChain") then
        local isPeer=lower:find("peer",1,true)~=nil
        local isEye=lower:find("eyes",1,true)~=nil or lower:find("eying",1,true)~=nil or lower:find("eyeing",1,true)~=nil
        if isPeer or isEye then
            local kind=isPeer and "peer" or "eye"
            local previous=session.scrutiny
            if previous and previous.kind~=kind and previous.actor~=actor and (now-previous.at)<=7 and (now-(session.lastScrutinyAward or 0))>2 then
                session.lastScrutinyAward=now;session.scrutiny=nil
                AwardTalentRenown("Read the Room",6,"talent:read_the_room")
            else
                session.scrutiny={actor=actor,kind=kind,at=now}
            end
        end
    end

    -- The same character headpats, then smiles within eight seconds.
    if HasEffectFlag("affectionateSequence") then
        local isPet=lower:find(" pets ",1,true)~=nil or lower:find("pets you",1,true)~=nil or lower:find("petting",1,true)~=nil
        local isSmile=lower:find("smile",1,true)~=nil
        if isPet then
            session.warmth={actor=actor,at=now}
        elseif isSmile and session.warmth and session.warmth.actor==actor and (now-session.warmth.at)<=8 and (now-(session.lastWarmthAward or 0))>2 then
            session.lastWarmthAward=now;session.warmth=nil
            AwardTalentRenown("Warmth After a Headpat",5,"talent:familiar_warmth")
        end
    end

    -- /snicker + /smirk from different characters within six seconds.
    if HasEffectFlag("mischiefEcho") then
        local isSnicker=lower:find("snicker",1,true)~=nil
        local isSmirk=lower:find("smirk",1,true)~=nil
        if isSnicker or isSmirk then
            local kind=isSnicker and "snicker" or "smirk"
            local previous=session.mischief
            if previous and previous.kind~=kind and previous.actor~=actor and (now-previous.at)<=6 and (now-(session.lastMischiefAward or 0))>2 then
                session.lastMischiefAward=now;session.mischief=nil
                AwardTalentRenown("Whispered Amusement",5,"talent:whispered_amusement")
            else
                session.mischief={actor=actor,kind=kind,at=now}
            end
        end
    end

    -- Any three distinct characters directing emotes at the player in 10 sec.
    if HasEffectFlag("crowdMoment") then
        local clean={}
        for who,at in pairs(session.crowd) do
            if (now-at)<=10 then clean[who]=at end
        end
        clean[actor]=now
        session.crowd=clean
        local count=0
        for _ in pairs(clean) do count=count+1 end
        if count>=3 and (now-(session.lastCrowdAward or 0))>2 then
            session.crowd={}
            session.lastCrowdAward=now
            AwardTalentRenown("Center of the Circle",5,"talent:center_of_circle")
        end
    end

    -- Chronicle emotional reversal: sorrow -> joy from a different actor.
    if HasEffectFlag("contrastMoment") then
        local sorrow=lower:find("cries",1,true) or lower:find("cry",1,true) or lower:find("frown",1,true)
        local joy=lower:find("smile",1,true) or lower:find("cheer",1,true)
        if sorrow then
            session.sorrow={actor=actor,at=now}
        elseif joy and session.sorrow and session.sorrow.actor~=actor and (now-session.sorrow.at)<=8 and (now-(session.lastContrastAward or 0))>2 then
            session.lastContrastAward=now
            session.sorrow=nil
            AwardTalentRenown("The Turn of the Session",8,"talent:turn_of_scene")
        end
    end
end

function RPAL.OnTalentStatisticScored(key,actor)
    local state=EnsureTalentState()
    if not state then return end
    local now=GetTime and GetTime() or 0
    local session=SessionState()

    -- Build cadences introduced by the ring Atlas.  Each cadence has its own
    -- persistent counter, allowing capstones to create distinct play styles.
    ForEachAllocatedEffect(function(effect)
        local cadence=effect.cadence
        if not cadence then return end
        local matches=cadence.all or (cadence.keys and cadence.keys[key])
        if not matches then return end
        local cadenceID=tostring(cadence.id or "generic")
        state.counters.cadence=state.counters.cadence or {}
        state.counters.cadence[cadenceID]=(tonumber(state.counters.cadence[cadenceID]) or 0)+1
        local everyN=math.max(1,math.floor(tonumber(cadence.every) or 1))
        if (state.counters.cadence[cadenceID]%everyN)==0 then
            AwardTalentRenown(cadence.title or "Build Cadence",tonumber(cadence.bonus) or 1,"talent:cadence_"..cadenceID)
        end
    end)

    -- Persistent every-fifth counter.
    local every,bonus
    ForEachAllocatedEffect(function(effect)
        if effect.storyBeat then
            every=math.max(every or 0,tonumber(effect.storyBeat.every) or 0)
            bonus=math.max(bonus or 0,tonumber(effect.storyBeat.bonus) or 0)
        end
    end)
    if every and every>0 then
        state.counters.storyBeat=(tonumber(state.counters.storyBeat) or 0)+1
        if (state.counters.storyBeat%every)==0 then
            AwardTalentRenown("Every Fifth Beat",bonus or 5,"talent:story_beat")
        end
    end

    if key=="nod" then
        local nodEvery,nodBonus
        ForEachAllocatedEffect(function(effect)
            if effect.nodCadence then
                nodEvery=math.max(nodEvery or 0,tonumber(effect.nodCadence.every) or 0)
                nodBonus=math.max(nodBonus or 0,tonumber(effect.nodCadence.bonus) or 0)
            end
        end)
        if nodEvery and nodEvery>0 then
            state.counters.nodCadence=(tonumber(state.counters.nodCadence) or 0)+1
            if (state.counters.nodCadence%nodEvery)==0 then
                AwardTalentRenown("The Room Agrees",nodBonus or 4,"talent:nod_cadence")
            end
        end
    end

    if HasEffectFlag("threefoldGreeting") and (key=="wave" or key=="nod" or key=="smile") then
        local clean={}
        for who,at in pairs(session.greeting) do
            if (now-at)<=12 then clean[who]=at end
        end
        clean[tostring(actor or "")]=now
        session.greeting=clean
        local count=0
        for _ in pairs(clean) do count=count+1 end
        if count>=3 and (now-(session.lastGreetingAward or 0))>2 then
            session.greeting={}
            session.lastGreetingAward=now
            AwardTalentRenown("Threefold Greeting",6,"talent:threefold_greeting")
        end
    end
end

local function TalentBranchSpentFromAllocated(allocated,branch)
    local spent=0
    for id in pairs(allocated or {}) do
        if id~="origin" then
            local node=RPAL.TALENT_NODES[id]
            if node and node.branch==branch then spent=spent+(tonumber(node.cost) or 1) end
        end
    end
    return spent
end

local function TalentTotalSpentFromAllocated(allocated)
    local spent=0
    for id in pairs(allocated or {}) do
        if id~="origin" then
            local node=RPAL.TALENT_NODES[id]
            if node then spent=spent+(tonumber(node.cost) or 1) end
        end
    end
    return spent
end

local function ParentRequirementMetForAllocated(node,allocated)
    local parents=node and node.parents
    if not parents or #parents==0 then return true end
    if node.anyParent then
        for _,parentID in ipairs(parents) do if allocated[parentID]~=nil then return true end end
        return false
    end
    for _,parentID in ipairs(parents) do if allocated[parentID]==nil then return false end end
    return true
end

local function TalentRequirementValidForAllocated(node,allocated)
    if not node or node.id=="origin" then return true end
    if not ParentRequirementMetForAllocated(node,allocated) then return false,"connection" end
    if node.requiresTalent and allocated[node.requiresTalent]==nil then return false,"required talent" end
    if node.requiresBranchSpent then
        for branch,needed in pairs(node.requiresBranchSpent) do
            if TalentBranchSpentFromAllocated(allocated,branch)<(tonumber(needed) or 0) then return false,"branch investment" end
        end
    end
    if node.requiresTotalSpent and TalentTotalSpentFromAllocated(allocated)<(tonumber(node.requiresTotalSpent) or 0) then
        return false,"total investment"
    end
    return true
end

function RPAL.CanRefundTalent(id)
    if RPAL.IsLiteKit and RPAL.IsLiteKit() then return false,"Talents are disabled in Lite Kit." end
    local state=EnsureTalentState()
    local node=RPAL.TALENT_NODES[id]
    if not state or not node then return false,"Unknown talent." end
    if id=="origin" then return false,"The origin cannot be refunded." end
    if state.allocated[id]==nil then return false,"Talent is not learned." end

    local before={}
    local after={}
    for learnedID,value in pairs(state.allocated or {}) do
        before[learnedID]=value
        if learnedID~=id then after[learnedID]=value end
    end

    -- PoE-style refund rule: removing this node is allowed only if it does not
    -- sever or invalidate another currently valid learned node.
    for learnedID in pairs(after) do
        if learnedID~="origin" then
            local learned=RPAL.TALENT_NODES[learnedID]
            if learned then
                local wasValid=TalentRequirementValidForAllocated(learned,before)
                local stillValid,why=TalentRequirementValidForAllocated(learned,after)
                if wasValid and not stillValid then
                    return false,"Still supporting "..tostring(learned.title or learnedID).." ("..tostring(why or "path")..")."
                end
            end
        end
    end
    return true
end

function RPAL.RefundTalent(id)
    local ok,reason=RPAL.CanRefundTalent(id)
    if not ok then return false,reason end
    local state=EnsureTalentState()
    state.allocated[id]=nil
    -- Cadence progress belongs to the active build. Reset counters rather than
    -- allowing an unlearned payoff to retain hidden partial progress.
    state.counters={}
    RPAL._talentSession=nil
    if RPAL.OnTalentChanged then RPAL.OnTalentChanged("refund",id) end
    return true
end

function RPAL.RespecTalents()
    if RPAL.IsLiteKit and RPAL.IsLiteKit() then return false,"Talents are disabled in Lite Kit." end
    local state=EnsureTalentState()
    if not state then return false,"Talent state is unavailable." end
    -- Cosmetic rewards and unveiled Memoirs are permanent progression
    -- rewards. A respec only removes active talent effects and path choices.
    state.allocated={origin=0}
    state.counters={}
    RPAL._talentSession=nil
    if RPAL.OnTalentChanged then RPAL.OnTalentChanged("respec") end
    return true
end
