risum 모듈 — RISU modules[] — 기능 확장
조회 4095 · 2026-06-18
프로톤 시뮬에서도 여캐들 이것저것 딸깍으로 하려고 만든 모듈 복잡한 건 빼고 꽤나 간단하게. 그냥 보통 하는 것처럼 인풋넣으면서 하다가 필요할 때 버튼 눌러서 즐기면 됨 기본적으로 패배든 실패든 하면 능욕 H씬을 당하는 것이 전제 페르소나를 여캐로 해서 여성 캐릭터 목록에 넣어서 해도 되고 페르소나 없이 관찰자로 해도 되고 남캐로는 안해봄 관심없어서 테스트한 건 연희무쌍 플레이 방법 먼저 첫 메시지의 마지막에 ▣◆ 를 복사 여캐 이름 추가하고 확정 한 번에 다 추가할 필요는 없고 하면서 추가할 수 있음. 간이 상태창은 상태창이 없는 봇 등에서 사용, 혹은 로어북 끄고 대체 이 후 처음 인풋은 마음대로 간이 상태창은 위와 같이 나옴 처음 등록될 때는 이렇게 아무 정보도 없음 활성화 키는 로어북과 별개로, 정보 가져올 때 캐릭터 활성화 시에 필요한 키 기본적으로 이름에 공백있으면 따로 가져옴. (이름이 가나다 마바사 이면 가나다/마바사 이렇게 따로 검색) 첫경험 설정 가능하고 맨 아래에 이렇게 나옴 기본적으로 자유 모드. 즉, 그냥 보통 하는 것과 다를 바 없이 인풋 넣으면서 하는 거임. 캐릭터 정보 보기 직접 인풋에 쓴 캐릭터를 활성화에 넣을 때 쓰는데 거의 쓰는 경우 없는 듯. 하려면 전송눌렀다가 취소한 후 눌러줘야 되기도 하고 이벤트 시에 결과를 표시 해줌 신음 지시문 추가 이벤트에 대한 자세한 설명은 아래에서 첫 경험은 따로 설정안해주면 알아서 설정됨. 캐릭터에 맞게 첫 경험 여부를 설정할 수 있음 굳이 안해도 처음에 AI가 로어북 참고해서 설정하게끔 되어있긴 함 버튼 설명 상황 종결: 현재 상황을 마무리까지 진행 일상 스킵: 선택된 히로인의 일반적인 일상을 넘기면서 어떤 일이 발생할 때까지 진행 사건 발생 / 랜덤 사건 발생: 특정 히로인에 대해 어떤 일을 발생 시킨 후 선택지 생성. 뭐해야 될 지 모를 때 쓰려고 만듬 이벤트 아무 때나 상황에 누르면 됨 전투든 다른 상황이든 범용적으로 작동 (아래는 다른 상황) 상대의 2개의 선택에 대해 3개의 선택지 중 하나 씩 선택 선택지에 따라 결과가 숨겨져 있음 토글 켜놓으면 인풋에서 선택 후 확인 가능 각 결과는 좋거나 나쁘거나 성추행 당하는 결과 (운빨임) 2턴부터는 2번째 선택지에 이벤트가 종료되는 결과가 포함됨 3턴부터는 2번째 선택지는 모두 이벤트를 종료시킴 최대 3턴으로 한 이벤트 종료 이벤트 종료는 좋게 끝나거나, 중립적으로 끝나거나, 무력화되어 H씬으로 이어지는 것으로 총 3가지로 나뉨 무력화된게 아니라면 이벤트가 끝난 후 자유 모드로 돌아와짐 아래처럼 인풋이랑 함께 이벤트 시작하는 것도 가능 H씬 이벤트에서 무력화되거나 H씬 선택지 등을 누르면 선택지가 나옴 이벤트가 끝나 자유 모드로 돌아온 상태이기 때문에 꼭 선택지 안눌러도 됨. 이벤트와 마찬가지로 선택지에 따라 결과가 달라짐 토글을 켜놓으면 결과를 인풋에서 미리 확인 가능 상대의 의도대로 당하거나, 다른 곳으로 돌리거나, 아니면 더 심하게 당하거나 3턴 째에는 상황 종료 버튼이 생겨서 H씬을 종료할 수 있음 H씬이 종료된 후에는 5가지 선택지가 나옴 자유 모드로 돌아온 상태이기 때문에 꼭 선택지를 누를 필요는 없음 H씬이 종료되면 히스토리에 기록 5개까지의 H씬은 저렇게 표시되고, 그보다 이전의 H씬의 경우 더 간소화된 요약으로 기록됨 자위 상황에 맞게 자위함 선택지는 최대 3개까지 선택 가능하고 절정 후 자위 중단 눌러서 종료 가능 선택지 생성 선택지 생성을 누르고 인풋을 보내면 응답의 마지막에 선택지가 생성됨 여전히 자유모드이기 때문에 선택지를 눌러도 되고 안누르고 그냥 다른거 해도 됨 커스텀 선택지 생성 선택지에 나오게 하고 싶은 걸 입력하면 관련 선택지가 나옴 여러가지로 활용 가능 H씬 선택지 생성 / 커스텀 H씬 선택지 생성 바로 H씬으로 직행하는 선택지 생성 커스텀 H씬 선택지 생성 랜덤 이벤트 불행 관심 기류 이 3가지는 이벤트가 시작되지 않고, 지나가는 일상 이벤트 발생한 랜덤 이벤트를 토대로 선택지 생성을 통해 이야기를 이어나갈 수도 있음 그 외의 랜덤 이벤트들은 이벤트가 시작되거나, 자위하는 상황이 되거나 H씬 상황이 됨 그 외 흥분 히로인들의 흥분상태가 표시됨 로어북에 현재 상태가 기록 성적 특성 / 지속 효과 이벤트 중에 성희롱을 당하거나 자위 이벤트를 하거나 H씬이 종료되면 성적특성을 얻거나 가지고 있던 성적 특성이 강화됨 성적 지속효과는 드물게 최면, 음문 등 지속되는 효과를 부여받은 경우 얻게 됨 불러오기 아웃풋을 지우고 이전 아웃풋으로 돌아가서 선택지를 누르거나 인풋을 쓸 때는 불러오기를 눌러줘야 함. 진행중에 맘에 안들거나 돌아가고 싶을 때 언제든지 전에 했던 출력으로 돌아가기 가능. Violated Girl은 초반엔 괜찮은데 나중가면 늘어지는 것도 아쉽고 한 명만 할 수 있는것도 아쉬운 점이 있어서 시뮬용으로 즐기려고 만듬. 스탯이니 뭐니 이것저것 복잡한 거 다 빼고 필요한 것만 넣고 시뮬용이라고는 해도 1인으로 해도 되고 페르소나 여캐로 해도 되고 다만 이벤트든 H씬이든 운빨이라 억까를 감당하거나 결과 표시 토글을 키거나 하면 될 듯. 업데이트 Ver. 202606191816: 1일차. 하면서 이것저것 수정 Ver. 202606211044: 렉 줄임. <style>을 정규식 OUT안에 집어넣으면 엄청 렉걸리는구나. Ver.202607011107 모바일대응
첫 메시지에 ▶▣◆를 복사 - Ver.202607011107
{
"name": "🔞Heroine Record",
"description": "첫 메시지에 ▶▣◆를 복사 - Ver.202607011107",
"id": "8e8e234a-c2bf-43e1-bb02-cdfb259b0e28",
"hideIcon": false,
"regex": [
{
"comment": "==== 시스템 ====",
"in": "",
"out": "",
"type": "disabled",
"ableFlag": false
},
{
"comment": "기호 리퀘스트",
"in": "(▶▣◆|◐◎◑)",
"out": "",
"type": "editprocess",
"ableFlag": false
},
{
"comment": "▶▣◆ 시작 Display",
"in": "▶▣◆",
"out": "{{#if {{? ({{greater_equal::{{chat_index}}::{{? {{lastmessageid}}-1}}}}) && ({{equal::{{getvar::hr_Init}}::null}})}} }}\n<style>\n.hr-start-panel {\n background: linear-gradient(135deg, rgba(18, 26, 42, 0.96) 0%, rgba(9, 14, 24, 0.98) 100%);\n padding: 25px 25px;\n margin: 20px auto;\n max-width: 1000px;\n border: 1px solid rgba(100, 150, 215, 0.42);\n color: rgba(220, 232, 248, 0.92);\n box-sizing: border-box;\n font-family: 'SF Pro Text', 'Apple SD Gothic Neo', 'Pretendard', sans-serif;\n}\n\n/* 행 레이아웃 */\n.hr-start-row {\n display: flex;\n justify-content: center;\n align-items: stretch;\n gap: 15px;\n margin: 0;\n padding: 0;\n}\n\n/* 구분선 */\n.hr-start-step-divider {\n height: 1px;\n background: rgba(100, 150, 215, 0.22);\n margin: 20px 0;\n}\n\n/* 라벨 */\n.hr-start-label {\n min-width: 70px;\n font-size: 18px;\n font-weight: 700;\n color: rgba(145, 185, 235, 0.92);\n display: flex;\n align-items: center;\n justify-content: center;\n white-space: nowrap;\n letter-spacing: 0.04em;\n}\n\n/* 기본 버튼 */\n.hr-start-button {\n all: unset;\n background: rgba(18, 29, 48, 0.9);\n border: 1px solid rgba(100, 150, 215, 0.34);\n border-radius: 10px;\n padding: 9px 20px;\n font-size: 16px;\n cursor: pointer;\n transition: all 0.18s ease;\n text-align: center;\n color: rgba(215, 230, 248, 0.9);\n flex-grow: 1;\n box-sizing: border-box;\n min-width: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n line-height: 1.3;\n}\n\n/* 버튼 호버 */\n.hr-start-button:hover {\n border-color: rgba(135, 185, 240, 0.7);\n background: rgba(25, 39, 62, 0.96);\n color: rgba(235, 245, 255, 0.96);\n transform: translateY(-1px);\n}\n\n/* 토글 버튼 */\n.hr-start-toggle-button {\n font-size: 15px;\n font-weight: bold;\n}\n\n/* 메인 버튼 */\n.hr-start-main-button {\n font-size: 15px;\n font-weight: bold;\n}\n\n/* 활성화 상태 */\n.hr-start-active {\n border-color: rgba(155, 205, 255, 0.86) !important;\n color: rgba(238, 247, 255, 0.98) !important;\n background: rgba(38, 60, 90, 0.96) !important;\n font-weight: bold;\n}\n\n/* 모바일 보정 */\n@media (max-width: 600px) {\n .hr-start-panel {\n padding: 20px 15px;\n border-radius: 12px;\n }\n\n .hr-start-row {\n gap: 8px;\n }\n\n .hr-start-label {\n min-width: 58px;\n font-size: 15px;\n }\n\n .hr-start-button {\n padding: 8px 10px;\n font-size: 14px;\n }\n}\n</style>\n\n<div class=\"hr-start-panel\">\n\n <div class=\"hr-start-row\">\n <div class=\"hr-start-label\">언어</div>\n\n <button class=\"hr-start-button hr-start-toggle-button {{#if {{equal::{{getvar::hr_Language}}::1}}}}hr-start-active{{/if}}\" risu-trigger=\"setLanguage1\">\n 한글\n </button>\n\n <button class=\"hr-start-button hr-start-toggle-button {{#if {{equal::{{getvar::hr_Language}}::2}}}}hr-start-active{{/if}}\" risu-trigger=\"setLanguage2\">\n English\n </button>\n\n <button class=\"hr-start-button hr-start-toggle-button {{#if {{equal::{{getvar::hr_Language}}::3}}}}hr-start-active{{/if}}\" risu-trigger=\"setLanguage3\">\n 日本語\n </button>\n </div>\n\n <div class=\"hr-start-step-divider\"></div>\n\n <div class=\"hr-start-row\">\n <div class=\"hr-start-label\">주요 캐릭터</div>\n\n <button class=\"hr-start-button hr-start-main-button\" risu-trigger=\"setHeroineList\">\n 여성 캐릭터 목록\n </button>\n </div>\n\n <div class=\"hr-start-step-divider\"></div>\n\n <div class=\"hr-start-row\">\n <div class=\"hr-start-label\">설정</div>\n\n <button class=\"hr-start-button hr-start-toggle-button {{#if {{equal::{{getvar::hr_Option_StatusWindow}}::1}}}}hr-start-active{{/if}}\" risu-trigger=\"toggleStatusWindow\">\n 간이 상태창\n </button>\n </div>\n\n <div class=\"hr-start-step-divider\"></div>\n\n <div class=\"hr-start-row\">\n <button class=\"hr-start-button hr-start-main-button\" risu-trigger=\"initSetting\">\n 확정\n </button>\n </div>\n\n</div>\n{{/if}}\n{{#if {{? ({{greater_equal::{{chat_index}}::{{? {{lastmessageid}}-1}}}}) && ({{equal::{{getvar::hr_Init}}::1}})}} }}\n◐◎◑\n{{/if}}",
"type": "editdisplay",
"ableFlag": false
},
{
"comment": "◐◎◑ 캐릭터 리스트",
"in": "◐◎◑",
"out": "{{#if {{greater_equal::{{chat_index}}::{{? {{lastmessageid}}-1}}}}}}\n<div class=\"hr-select-panel\">\n\n <input class=\"hr-info-radio\" type=\"radio\" id=\"hr-info-none\" name=\"hr-info-toggle\" checked>\n <input class=\"hr-info-radio\" type=\"radio\" id=\"hr-info-selected\" name=\"hr-info-toggle\">\n\n <div class=\"hr-select-row\">\n\n <div class=\"hr-select-col hr-select-col-heroine\">\n <div class=\"hr-select-title hr-select-title-add\">\n <span class=\"hr-select-title-text\">히로인</span>\n <button class=\"hr-add-button\" risu-trigger=\"addHeroineCharacter\" title=\"캐릭터 추가\">➕</button>\n </div>\n\n <div class=\"hr-select-list\">\n {{getvar::hr_Heroine_Display}}\n </div>\n </div>\n\n <div class=\"hr-select-col hr-select-col-center\">\n <div class=\"hr-center-panel\">\n <div class=\"hr-select-title\">\n {{getvar::hr_Phase_Title}}\n </div>\n\n <div class=\"hr-center-body\">\n {{getvar::hr_Phase_Body}}\n </div>\n </div>\n </div>\n\n <div class=\"hr-select-col hr-select-col-detail\">\n <div class=\"hr-select-detail\">\n\n <div class=\"hr-select-title hr-select-control-row\">\n <div class=\"hr-info-control-left\">\n <label for=\"hr-info-selected\" class=\"hr-event-button\" title=\"캐릭터 정보\">⚜️</label>\n <button class=\"hr-event-button\" risu-trigger=\"searchMentionedCharacter\" title=\"활성화 캐릭터 갱신\">🔍</button>\n <button class=\"hr-event-button {{#if {{equal::{{getvar::hr_Option_Cheat}}::1}}}}hr-toggle-active{{/if}}\" risu-trigger=\"toggleCheat\" title=\"결과 미리보기\">👓</button>\n <button class=\"hr-event-button {{#if {{equal::{{getvar::hr_Option_Result}}::1}}}}hr-toggle-active{{/if}}\" risu-trigger=\"toggleDisplayResult\" title=\"결과 표시\">🔭</button>\n <button class=\"hr-event-button {{#if {{equal::{{getvar::hr_Option_Moan}}::1}}}}hr-toggle-active{{/if}}\" risu-trigger=\"toggleMoanDirective\" title=\"신음 지시문\">💋</button>\n </div>\n\n <button class=\"hr-event-button\" risu-trigger=\"loadData\" title=\"불러오기\">📥</button>\n </div>\n\n <div class=\"hr-select-selected-row\">\n 선택: {{getvar::hr_Selected_Name}}\n </div>\n\n <div class=\"hr-select-selected-row\">\n 활성화: {{getvar::hr_Mentioned_List}}\n </div>\n\n <div class=\"hr-select-detail-fill\"></div>\n\n </div>\n </div>\n\n </div>\n\n <div class=\"hr-info-drawer hr-info-drawer-selected\">\n <div class=\"hr-info-header\">\n <div class=\"hr-info-title\">{{getvar::hr_Selected_Name}}</div>\n <label for=\"hr-info-none\" class=\"hr-info-close\" title=\"닫기\">✖</label>\n </div>\n\n <div class=\"hr-info-content\">\n {{getvar::hr_Selected_Info_Display}}\n </div>\n </div>\n\n</div>\n{{/if}}",
"type": "editdisplay",
"ableFlag": false
},
{
"comment": "<Conception Risk Recorded> Request",
"in": "<Conception Risk Recorded>([\\s\\S]*?)</Conception Risk Recorded>",
"out": "{{#if {{? {{getvar::hr_List_ConceptionRisk}} != null}}}}\n$&\n{{/if}}",
"type": "editprocess",
"ableFlag": false
},
{
"comment": "<Physiological Arousal State> Request",
"in": "<Physiological Arousal State>([\\s\\S]*?)</Physiological Arousal State>",
"out": "{{#if {{? ({{getvar::hr_ArousalCheck_Flag}} == 1) && ({{getvar::hr_List_Heroine}} != null) }} }}\n$&\n{{/if}}",
"type": "editprocess",
"ableFlag": false
},
{
"comment": "<> Display",
"in": "(<SaveFullData>[\\s\\S]*?</SaveFullData>|<SaveSessionData>[\\s\\S]*?</SaveSessionData>|<Conception Risk Recorded>[\\s\\S]*?</Conception Risk Recorded>|<Physiological Arousal State>[\\s\\S]*?</Physiological Arousal State>|<Scene Snapshot>|</Scene Snapshot>|<Imposed Sexual Effect>[\\s\\S]*?</Imposed Sexual Effect>|<Event Record>[\\s\\S]*?</Event Record>)",
"out": "",
"type": "editdisplay",
"ableFlag": false
},
{
"comment": "<> Request",
"in": "(<SaveFullData>[\\s\\S]*?</SaveFullData>|<SaveSessionData>[\\s\\S]*?</SaveSessionData>|<Choice>[\\s\\S]*?</Choice>|<Event Record>[\\s\\S]*?</Event Record>)",
"out": "",
"type": "editprocess",
"ableFlag": false
},
{
"comment": "<Choice> Display",
"in": "<Choice>[\\s\\S]*?</Choice>",
"out": "{{#if {{greater_equal::{{chat_index}}::{{? {{lastmessageid}}-1}}}}}}\n$&\n{{/if}}",
"type": "editdisplay",
"ableFlag": false
},
{
"comment": "<Choice>",
"in": "<Choice>",
"out": "{{#if {{greater_equal::{{chat_index}}::{{? {{lastmessageid}}-1}}}}}}<br>{{/if}}",
"type": "editdisplay",
"ableFlag": false
},
{
"comment": "[Blank Line]",
"in": "\\[Blank Line\\]",
"out": "{{#if {{greater_equal::{{chat_index}}::{{? {{lastmessageid}}-5}}}}}}<br>{{/if}}",
"type": "editdisplay",
"ableFlag": false
},
{
"comment": "[Blank Line] Request",
"in": "\\[Blank Line\\]",
"out": "",
"type": "editprocess",
"ableFlag": false
},
{
"comment": "==== [시스템 메시지] ====",
"in": "",
"out": "",
"type": "disabled",
"ableFlag": false
},
{
"comment": "[HR Event Start] Display",
"in": "\\[HR Event Start: ([^\\]]+)\\]",
"out": "{{#if {{greater_equal::{{chat_index}}::{{? {{lastmessageid}}-5}}}}}}\n{{#if {{not_equal::$1::NULL}}}}<div style=\"text-align: center;\">\n<div class=\"hr-systemlog\">\n$1\n</div>\n</div>{{/if}}\n{{/if}}\n{{#if {{less::{{chat_index}}::{{? {{lastmessageid}}-5}}}}}}\n{{#if {{not_equal::$1::NULL}}}}*$1*{{/if}}\n{{/if}}",
"type": "editdisplay",
"ableFlag": false
},
{
"comment": "[HR Event Start] Request",
"in": "\\[HR Event Start: ([^\\]]+)\\]",
"out": "{{#if {{greater_equal::{{chat_index}}::{{? {{lastmessageid}}-1}}}}}}\n({{getvar::hr_Event_Start}})\n{{/if}}",
"type": "editprocess",
"ableFlag": false
},
{
"comment": "[HR Event Action] Display",
"in": "\\[HR Event Action\\]",
"out": "",
"type": "editdisplay",
"ableFlag": false
},
{
"comment": "[HR Event Action] Request",
"in": "\\[HR Event Action\\]",
"out": "{{#if {{greater_equal::{{chat_index}}::{{? {{lastmessageid}}-1}}}}}}\n{{getvar::hr_Event_Action}}\n{{/if}}",
"type": "editprocess",
"ableFlag": false
},
{
"comment": "[HR Hidden Message] Display",
"in": "\\[HR Hidden Message\\]",
"out": "",
"type": "editdisplay",
"ableFlag": false
},
{
"comment": "[HR Hidden Message] Request",
"in": "\\[HR Hidden Message\\]",
"out": "{{#if {{greater_equal::{{chat_index}}::{{? {{lastmessageid}}-1}}}}}}\n({{getvar::hr_Event_Message}})\n{{/if}}",
"type": "editprocess",
"ableFlag": false
},
{
"comment": "[HR Event End] Display",
"in": "\\[HR Event End: ([^\\]]+)\\]",
"out": "{{#if {{greater_equal::{{chat_index}}::{{? {{lastmessageid}}-5}}}}}}\n<div class=\"hr-action-list\">\n <div class=\"hr-action-card\">\n <div class=\"hr-action-title\">\n <span class=\"hr-action-name\">$1</span>\n </div>\n </div>\n</div>\n{{/if}}\n{{#if {{less::{{chat_index}}::{{? {{lastmessageid}}-5}}}}}}\n*$1*\n{{/if}}",
"type": "editdisplay",
"ableFlag": false
},
{
"comment": "[HR Event End] Request",
"in": "\\[HR Event End: ([^\\]]+)\\]",
"out": "{{#if {{greater_equal::{{chat_index}}::{{? {{lastmessageid}}-1}}}}}}\n({{getvar::hr_Event_End}})\n{{/if}}",
"type": "editprocess",
"ableFlag": false
},
{
"comment": "==== 상태창 ====",
"in": "",
"out": "",
"type": "disabled",
"ableFlag": false
},
{
"comment": "Date",
"in": "\\[Date: ([0-9]+-[0-9][0-9]-[0-9][0-9]) \\| Time: ([0-9][0-9]?):([0-9][0-9]) (AM|PM) \\| Location: ([^\\]]+)\\]",
"out": "{{#if {{greater_equal::{{chat_index}}::{{? {{lastmessageid}}-5}}}}}}\n<div style=\"text-align: center;\">\n<div class=\"hr-systemlog\">\n$1 $2:$3 $4 $5\n</div>\n</div>\n{{/if}}\n{{#if {{less::{{chat_index}}::{{? {{lastmessageid}}-5}}}}}}\n*$1 $2:$3 $4 $5*\n{{/if}}",
"type": "editdisplay",
"ableFlag": false
},
{
"comment": "Heroine",
"in": "\\[Heroine: ([^|\\]]+) \\| Clothing: ([^|\\]]+) \\| Underwear: ([^\\]]+)\\]",
"out": "{{#if {{greater_equal::{{chat_index}}::{{? {{lastmessageid}}-5}}}}}}\n<div style=\"text-align: center;\">\n<div class=\"hr-systemlog\">\n🌸 $1 ($2 / $3)\n</div>\n</div>\n{{/if}}\n{{#if {{less::{{chat_index}}::{{? {{lastmessageid}}-5}}}}}}\n*🌸 $1 ($2 / $3)*\n{{/if}}",
"type": "editdisplay",
"ableFlag": false
},
{
"comment": "Extra",
"in": "\\[Extra: ([^\\]]+)\\]",
"out": "{{#if {{greater_equal::{{chat_index}}::{{? {{lastmessageid}}-5}}}}}}\n<div style=\"text-align: center;\">\n<div class=\"hr-systemlog\">\n👥 $1\n</div>\n</div>\n{{/if}}\n{{#if {{less::{{chat_index}}::{{? {{lastmessageid}}-5}}}}}}\n*👥 $1*\n{{/if}}",
"type": "editdisplay",
"ableFlag": false
},
{
"comment": "==== 장면 전개 ====",
"in": "",
"out": "",
"type": "disabled",
"ableFlag": false
},
{
"comment": "상대편 Display",
"in": "\\[Event Development: ([^\\|\\]]+) \\| Source: ([^\\|\\]]+) \\| Icon: ([^\\|\\]]+) \\| Development: ([^\\]]+)\\]",
"out": "<div class=\"hr-action-list\"> \n <div class=\"hr-action-card hr-action-card-resolved-enemy\"> \n <div class=\"hr-action-title\"> \n <span class=\"hr-action-name\">$3 $2: $4</span> \n </div> \n </div> \n</div>",
"type": "editdisplay",
"ableFlag": false
},
{
"comment": "히로인 선택지 Display",
"in": "\\[Heroine Side Choice: ([12]) \\| Attempt: ([^|\\]]+) \\| Icon: ([^|\\]]+) \\| Outcome Type: ([0-9]+) \\| Result: ([^\\]]+)\\]",
"out": "<div class=\"hr-action-list\">\n <button class=\"hr-action-card\" risu-btn=\"choice_selectEventDevelop_$1_$2_$3_$4_$5\">\n <div class=\"hr-action-title\">\n <span class=\"hr-action-name\">$3 $2</span>\n </div>\n{{#if {{? ({{getvar::hr_Option_Cheat}} == 1)}} }}<div class=\"hr-action-effect\">$5</div>{{/if}}\n </button>\n</div>",
"type": "editdisplay",
"ableFlag": false
},
{
"comment": "아군 Display",
"in": "\\[Heroine Side Action: ([^|\\]]+) \\| Action: ([^|\\]]+) \\| Icon: ([^\\]]+)\\]",
"out": "<div class=\"hr-action-list\">\n <div class=\"hr-action-card hr-action-card-resolved-ally\">\n <div class=\"hr-action-title\">\n <span class=\"hr-action-name\">$3 $1: $2</span>\n </div>\n </div>\n</div>",
"type": "editdisplay",
"ableFlag": false
},
{
"comment": "상대편 선택 후 Display",
"in": "\\[Event Development Select: ([^|\\]]+) \\| Source: ([^|\\]]+) \\| Icon: ([^|\\]]+) \\| Development: ([^\\]]+)\\]",
"out": "{{#if {{greater_equal::{{chat_index}}::{{? {{lastmessageid}}-5}}}}}}\n<div class=\"hr-action-list\"> \n <div class=\"hr-action-card hr-action-card-resolved-enemy\"> \n <div class=\"hr-action-title\"> \n <span class=\"hr-action-name\">$3 $2: $4</span> \n </div> \n </div> \n</div>\n{{/if}}",
"type": "editdisplay",
"ableFlag": false
},
{
"comment": "상대편 선택 후 Request",
"in": "\\[Event Development Select: ([^|\\]]+) \\| Source: ([^|\\]]+) \\| Icon: ([^|\\]]+) \\| Development: ([^\\]]+)\\]",
"out": "",
"type": "editprocess",
"ableFlag": false
},
{
"comment": "히로인 선택 대기중 Display",
"in": "\\[Heroine Side Select: ([^|\\]]+) \\| Icon: ([^|\\]]+) \\| Attempt: ([^|\\]]+) \\| Result: ([^|\\]]+) \\| Pending\\]",
"out": "{{#if {{greater_equal::{{chat_index}}::{{? {{lastmessageid}}-5}}}}}}\n<div class=\"hr-action-list\">\n <div class=\"hr-action-card hr-action-card-resolved-player\">\n <div class=\"hr-action-title\">\n <span class=\"hr-action-name\">$2 $3 (대기중: 총 2개의 액션 선택)</span>\n </div>\n </div>\n</div>\n{{/if}}",
"type": "editdisplay",
"ableFlag": false
},
{
"comment": "히로인 선택 후 Display",
"in": "\\[Heroine Side Select: ([^|\\]]+) \\| Icon: ([^|\\]]+) \\| Attempt: ([^|\\]]+) \\| Result: ([^\\]]+)\\]",
"out": "{{#if {{greater_equal::{{chat_index}}::{{? {{lastmessageid}}-5}}}}}}\n<div class=\"hr-action-list\">\n <div class=\"hr-action-card hr-action-card-resolved-player\">\n <div class=\"hr-action-title\">\n <span class=\"hr-action-name\">$2 $3</span>\n </div>\n{{#if {{? {{getvar::hr_Option_Result}} == 1}}}} <div class=\"hr-action-effect\">$4</div>{{/if}}\n </div>\n</div>\n{{/if}}",
"type": "editdisplay",
"ableFlag": false
},
{
"comment": "히로인 선택 후 Request",
"in": "\\[Heroine Side Select: ([^|\\]]+) \\| Icon: ([^|\\]]+) \\| Attempt: ([^|\\]]+) \\| Result: ([^\\]]+)\\]",
"out": "",
"type": "editprocess",
"ableFlag": false
},
{
"comment": "아군 선택 후 Request",
"in": "\\[Heroine Side Action: ([^|\\]]+) \\| Action: ([^|\\]]+) \\| Icon: ([^\\]]+)\\]",
"out": "",
"type": "editprocess",
"ableFlag": false
},
{
"comment": "==== 자위 ====",
"in": "",
"out": "",
"type": "disabled",
"ableFlag": false
},
{
"comment": "자위 선택지 Display",
"in": "\\[Masturbation Choice: ([^|\\]]+) \\| Icon: ([^|\\]]+) \\| Focus: ([^\\]]+)\\]",
"out": "<div class=\"hr-action-list\">\n <button class=\"hr-action-card\" risu-btn=\"choice_selectMasturbation_$1_$2_$3\">\n <div class=\"hr-action-title\">\n <span class=\"hr-action-name\">$2 $1</span>\n </div>\n </button>\n</div>",
"type": "editdisplay",
"ableFlag": false
},
{
"comment": "자위 중단 선택지 Display",
"in": "\\[Masturbation End Choice: ([^|\\]]+) \\| Icon: ([^\\]]+)\\]",
"out": "<div class=\"hr-action-list\">\n <button class=\"hr-action-card\" risu-btn=\"choice_selectMasturbationEnd_$1_$2\">\n <div class=\"hr-action-title\">\n <span class=\"hr-action-name\">$2 $1</span>\n </div>\n </button>\n</div>",
"type": "editdisplay",
"ableFlag": false
},
{
"comment": "자위 선택 후 Display",
"in": "\\[Masturbation Select: ([^|\\]]+) \\| Icon: ([^|\\]]+) \\| Focus: ([^\\]]+)\\]",
"out": "{{#if {{greater_equal::{{chat_index}}::{{? {{lastmessageid}}-5}}}}}}\n<div class=\"hr-action-list\">\n <div class=\"hr-action-card hr-action-selected\">\n <div class=\"hr-action-title\">\n <span class=\"hr-action-name\">$2 $1</span>\n </div>\n </div>\n</div>\n{{/if}}",
"type": "editdisplay",
"ableFlag": false
},
{
"comment": "자위 선택 후 Request",
"in": "\\[Masturbation Select: ([^|\\]]+) \\| Icon: ([^|\\]]+) \\| Focus: ([^\\]]+)\\]",
"out": "{{#if {{greater_equal::{{chat_index}}::{{? {{lastmessageid}}-1}}}}}}\n*$1 ($3)*\n{{/if}}",
"type": "editprocess",
"ableFlag": false
},
{
"comment": "자위 중단 선택 후 Display",
"in": "\\[Masturbation End Select: ([^|\\]]+) \\| Icon: ([^\\]]+)\\]",
"out": "{{#if {{greater_equal::{{chat_index}}::{{? {{lastmessageid}}-5}}}}}}\n<div class=\"hr-action-list\">\n <div class=\"hr-action-card hr-action-selected\">\n <div class=\"hr-action-title\">\n <span class=\"hr-action-name\">$2 $1</span>\n </div>\n </div>\n</div>\n{{/if}}",
"type": "editdisplay",
"ableFlag": false
},
{
"comment": "자위 중단 선택 후 Request",
"in": "\\[Masturbation End Select: ([^|\\]]+) \\| Icon: ([^\\]]+)\\]",
"out": "",
"type": "editprocess",
"ableFlag": false
},
{
"comment": "==== H씬 루트 ====",
"in": "",
"out": "",
"type": "disabled",
"ableFlag": false
},
{
"comment": "H씬 선택지 Display",
"in": "\\[H Scene Choice: ([^|\\]]+) \\| Title: ([^|\\]]+) \\| Icon: ([^|\\]]+) \\| Location: ([^|\\]]+) \\| Setup: ([^\\]]+)\\]",
"out": "<div class=\"hr-action-list\">\n <button class=\"hr-action-card\" risu-btn=\"choice_selectHScene_$1_$2_$3_$4_$5\">\n <div class=\"hr-action-title\">\n <span class=\"hr-action-name\">$3 $2</span>\n </div>\n <div class=\"hr-action-effect\">$4 - $5</div>\n </button>\n</div>",
"type": "editdisplay",
"ableFlag": false
},
{
"comment": "H씬 선택 후 Display",
"in": "\\[H Scene Select: ([^|\\]]+) \\| Title: ([^|\\]]+) \\| Icon: ([^|\\]]+) \\| Location: ([^|\\]]+) \\| Setup: ([^\\]]+)\\]",
"out": "{{#if {{greater_equal::{{chat_index}}::{{? {{lastmessageid}}-5}}}}}}\n<div class=\"hr-action-list\">\n <div class=\"hr-action-card hr-action-card-resolved-player\">\n <div class=\"hr-action-title\">\n <span class=\"hr-action-name\">$3 $2</span>\n </div>\n <div class=\"hr-action-effect\">$4 - $5</div>\n </div>\n</div>\n{{/if}}\n{{#if {{less::{{chat_index}}::{{? {{lastmessageid}}-5}}}}}}\n*$3 $2*\n{{/if}}",
"type": "editdisplay",
"ableFlag": false
},
{
"comment": "H씬 선택 후 Request",
"in": "\\[H Scene Select: ([^|\\]]+) \\| Title: ([^|\\]]+) \\| Icon: ([^|\\]]+) \\| Location: ([^|\\]]+) \\| Setup: ([^\\]]+)\\]",
"out": "{{#if {{greater_equal::{{chat_index}}::{{? {{lastmessageid}}-1}}}}}}\n*$1: $2: $4 - $5*\n{{/if}}",
"type": "editprocess",
"ableFlag": false
},
{
"comment": "==== H씬 진행중 ====",
"in": "",
"out": "",
"type": "disabled",
"ableFlag": false
},
{
"comment": "H씬 액션 Display",
"in": "\\[Heroine Violation Choice: ([^|\\]]+) \\| Action: ([^|\\]]+) \\| Icon: ([^|\\]]+) \\| Resulting Sexual Act: ([^|\\]]+) \\| Resulting Focus: ([^\\]]+)\\]",
"out": "<div class=\"hr-action-list\">\n <button class=\"hr-action-card\" risu-btn=\"choice_selectViolationChoice_$1_$2_$3_$4_$5\">\n <div class=\"hr-action-title\">\n <span class=\"hr-action-name\">$3 $2</span>\n </div>\n{{#if {{? ({{getvar::hr_Option_Cheat}} == 1)}} }}<div class=\"hr-action-effect\">$4</div>{{/if}}\n </button>\n</div>",
"type": "editdisplay",
"ableFlag": false
},
{
"comment": "H씬 상대 Display",
"in": "\\[Violation Threat: ([^|\\]]+) \\| Order Emoji: ([^|\\]]+) \\| Actor: ([^|\\]]+) \\| Icon: ([^|\\]]+) \\| Intent: ([^|\\]]+) \\| Intent Sexual Act: ([^|\\]]+) \\| Intent Focus: ([^\\]]+)\\]",
"out": "<div class=\"hr-action-list\">\n <div class=\"hr-action-card hr-action-card-resolved-enemy\">\n <div class=\"hr-action-title\">\n <span class=\"hr-action-name\">$2 $3: $4 $5</span>\n </div>\n </div>\n</div>",
"type": "editdisplay",
"ableFlag": false
},
{
"comment": "H씬 액션 선택 대기중 Display",
"in": "\\[Heroine Violation Select: [^|\\]]+ \\| Icon: ([^|\\]]+) \\| Action: ([^|\\]]+) \\| Result: ([^|\\]]+) \\| Pending\\]",
"out": "{{#if {{greater_equal::{{chat_index}}::{{? {{lastmessageid}}-5}}}}}}\n<div class=\"hr-action-list\">\n <div class=\"hr-action-card hr-action-card-waiting-player\">\n <div class=\"hr-action-title\">\n <span class=\"hr-action-name\">$1 $2 (대기중: 총 2개의 액션 선택)</span>\n </div>\n </div>\n</div>\n{{/if}}",
"type": "editdisplay",
"ableFlag": false
},
{
"comment": "H씬 액션 선택 후 Display",
"in": "\\[Heroine Violation Select: [^|\\]]+ \\| Icon: ([^|\\]]+) \\| Action: ([^|\\]]+) \\| Result: ([^\\]]+)\\]",
"out": "{{#if {{greater_equal::{{chat_index}}::{{? {{lastmessageid}}-5}}}}}}\n<div class=\"hr-action-list\">\n <div class=\"hr-action-card hr-action-card-resolved-player\">\n <div class=\"hr-action-title\">\n <span class=\"hr-action-name\">$1 $2</span>\n </div>\n{{#if {{? {{getvar::hr_Option_Result}} == 1}}}} <div class=\"hr-action-effect\">$3</div>{{/if}}\n </div>\n</div>\n{{/if}}",
"type": "editdisplay",
"ableFlag": false
},
{
"comment": "H씬 액션 선택 후 Request",
"in": "\\[Heroine Violation Select: [^|\\]]+ \\| Icon: ([^|\\]]+) \\| Action: ([^|\\]]+) \\| Result: ([^\\]]+)\\]",
"out": "",
"type": "editprocess",
"ableFlag": false
},
{
"comment": "H씬 상대 선택 후 Display",
"in": "\\[Violation Threat Select: [^|\\]]+ \\| Actor: ([^|\\]]+) \\| Icon: ([^|\\]]+) \\| Intent: ([^\\]]+)\\]",
"out": "{{#if {{greater_equal::{{chat_index}}::{{? {{lastmessageid}}-5}}}}}}\n<div class=\"hr-action-list\">\n <div class=\"hr-action-card hr-action-card-resolved-enemy\">\n <div class=\"hr-action-title\">\n <span class=\"hr-action-name\">$2 $1: $3</span>\n </div>\n </div>\n</div>\n{{/if}}",
"type": "editdisplay",
"ableFlag": false
},
{
"comment": "H씬 상대 선택 후 Request",
"in": "\\[Violation Threat Select: [^|\\]]+ \\| Actor: ([^|\\]]+) \\| Icon: ([^|\\]]+) \\| Intent: ([^\\]]+)\\]",
"out": "",
"type": "editprocess",
"ableFlag": false
},
{
"comment": "H씬 상황 종료 Display",
"in": "\\[H Scene End: ([^\\]]+)\\]",
"out": "<div class=\"hr-action-list\">\n <button class=\"hr-action-card\" risu-trigger=\"triggerHsceneEnd\">\n <div class=\"hr-action-title\">\n <span class=\"hr-action-name\">$1</span>\n </div>\n </button>\n</div>",
"type": "editdisplay",
"ableFlag": false
},
{
"comment": "==== H씬 종료 ====",
"in": "",
"out": "",
"type": "disabled",
"ableFlag": false
},
{
"comment": "운명 선택지 Display",
"in": "\\[Fate Choice: ([^|\\]]+) \\| Situation: ([^|\\]]+) \\| Icon: ([^|\\]]+) \\| Time: ([^|\\]]+) \\| Location: ([^\\]]+)\\]",
"out": "<div class=\"hr-action-list\">\n <button class=\"hr-action-card\" risu-btn=\"choice_selectFate_$1_$2_$3_$4_$5\">\n <div class=\"hr-action-title\">\n <span class=\"hr-action-name\">$3 $2</span>\n </div>\n <div class=\"hr-action-effect\">$4, $5</div>\n </button>\n</div>",
"type": "editdisplay",
"ableFlag": false
},
{
"comment": "운명 선택 후 Display",
"in": "\\[Fate Select: ([^|\\]]+) \\| Situation: ([^|\\]]+) \\| Icon: ([^|\\]]+) \\| Time: ([^|\\]]+) \\| Location: ([^\\]]+)\\]",
"out": "{{#if {{greater_equal::{{chat_index}}::{{? {{lastmessageid}}-5}}}}}} \n<div class=\"hr-action-list\">\n <div class=\"hr-action-card hr-fate-select-card\">\n <div class=\"hr-action-title\">\n <span class=\"hr-action-name\">$3 $2</span>\n </div>\n <div class=\"hr-action-effect\">$4, $5</div>\n </div>\n</div>\n{{/if}}\n{{#if {{less::{{chat_index}}::{{? {{lastmessageid}}-5}}}}}} \n*$3 $2*\n{{/if}}",
"type": "editdisplay",
"ableFlag": false
},
{
"comment": "운명 선택 후 Request",
"in": "\\[Fate Select: ([^|\\]]+) \\| Situation: ([^|\\]]+) \\| Icon: ([^|\\]]+) \\| Time: ([^|\\]]+) \\| Location: ([^\\]]+)\\]",
"out": "{{#if {{greater_equal::{{chat_index}}::{{? {{lastmessageid}}-1}}}}}}\n(Heroine: $1\nTime Passage: $4\nLocation: $5\nSituation: $2\nDo not jump directly to the outcome defined by the specified time passage, location, and situation.\nFirst describe the events that occur during the intervening time, then naturally guide the story so that the heroine ultimately ends up at that time, in that location, and in that situation.\nRegardless of what occurred during the intervening time, the heroine must end in a temporarily settled state, not in the middle of an ongoing incident, unresolved crisis, or immediate new threat.)\n{{/if}}",
"type": "editprocess",
"ableFlag": false
},
{
"comment": "==== 루트 선택지 생성 ====",
"in": "",
"out": "",
"type": "disabled",
"ableFlag": false
},
{
"comment": "루트 선택지 Display",
"in": "\\[Route Choice: ([^|\\]]+) \\| Mode: ([^|\\]]+) \\| Icon: ([^|\\]]+) \\| Time: ([^|\\]]+) \\| Location: ([^|\\]]+) \\| Setup: ([^|\\]]+) \\| Type: ([^\\]]+)\\]",
"out": "<div class=\"hr-action-list\">\n <button class=\"hr-action-card\" risu-btn=\"choice_selectRoute_$1_$2_$3_$4_$5_$6_$7\">\n <div class=\"hr-action-title\">\n <span class=\"hr-action-name\">$3 $2</span>\n </div>\n <div class=\"hr-action-effect\">$4, $5 - $6</div>\n </button>\n</div>",
"type": "editdisplay",
"ableFlag": false
},
{
"comment": "루트 선택 후 Display",
"in": "\\[Route Select: ([^|\\]]+) \\| Mode: ([^|\\]]+) \\| Icon: ([^|\\]]+) \\| Time: ([^|\\]]+) \\| Location: ([^|\\]]+) \\| Setup: ([^\\]]+)\\]",
"out": "{{#if {{greater_equal::{{chat_index}}::{{? {{lastmessageid}}-5}}}}}}\n<div class=\"hr-action-list\">\n <div class=\"hr-action-card hr-action-card-resolved-player\">\n <div class=\"hr-action-title\">\n <span class=\"hr-action-name\">$3 $2</span>\n </div>\n <div class=\"hr-action-effect\">$4, $5 - $6</div>\n </div>\n</div>\n{{/if}}\n{{#if {{less::{{chat_index}}::{{? {{lastmessageid}}-5}}}}}}\n*$3 $2*\n{{/if}}",
"type": "editdisplay",
"ableFlag": false
},
{
"comment": "루트 선택 후 Request",
"in": "\\[Route Select: ([^|\\]]+) \\| Mode: ([^|\\]]+) \\| Icon: ([^|\\]]+) \\| Time: ([^|\\]]+) \\| Location: ([^|\\]]+) \\| Setup: ([^\\]]+)\\]",
"out": "{{#if {{greater_equal::{{chat_index}}::{{? {{lastmessageid}}-1}}}}}}\n(Heroine: $1\nTime Passage: $4\nLocation: $5\nSituation: $2 - $6\nDo not jump directly to the outcome defined by the specified time passage, location, and situation.\nFirst describe the events that occur during the intervening time, then naturally guide the story so that the heroine ultimately ends up at that time, in that location, and in that situation.)\n{{/if}}",
"type": "editprocess",
"ableFlag": false
},
{
"comment": "==== 로그 ====",
"in": "",
"out": "",
"type": "disabled",
"ableFlag": false
},
{
"comment": "시스템 로그 Display",
"in": "\\[Temporary Log: ([^\\]]+)\\]",
"out": "{{#if {{greater_equal::{{chat_index}}::{{? {{lastmessageid}}-5}}}}}}\n<div style=\"text-align: center;\">\n<div class=\"hr-systemlog\">\n$1\n</div>\n</div>\n{{/if}}\n{{#if {{less::{{chat_index}}::{{? {{lastmessageid}}-5}}}}}}\n*$1*\n{{/if}}",
"type": "editdisplay",
"ableFlag": false
},
{
"comment": "시스템 로그 Request",
"in": "\\[Temporary Log: ([^\\]]+)\\]",
"out": "",
"type": "editprocess",
"ableFlag": false
}
],
"backgroundEmbedding": "<style>\n.hr-systemlog {\n display: block;\n box-sizing: border-box;\n\n margin: 0;\n padding: 6px 12px;\n\n background: rgba(10, 18, 32, 0.92);\n border-left: 3px solid rgba(155, 205, 255, 0.92);\n\n color: rgba(215, 230, 248, 0.92);\n font-family: 'Consolas', 'Malgun Gothic', monospace;\n font-size: 16px;\n font-weight: 450;\n line-height: 1.35;\n text-align: left;\n\n box-shadow: none;\n text-shadow: none;\n}\n\n.hr-action-list {\n display: flex;\n flex-wrap: wrap;\n gap: 0;\n}\n\n.hr-action-card {\n display: flex;\n flex-direction: column;\n justify-content: center;\n align-items: flex-start;\n\n box-sizing: border-box;\n width: 100%;\n\n margin: 0;\n padding: 8px 14px 6px 14px;\n\n background: rgba(10, 18, 32, 0.92);\n border: 1px solid rgba(100, 150, 215, 0.42);\n\n color: rgba(215, 230, 248, 0.92);\n font-family: 'Consolas', 'Malgun Gothic', monospace;\n text-align: left;\n\n box-shadow: none;\n text-shadow: none;\n}\n\nbutton.hr-action-card {\n cursor: pointer;\n appearance: none;\n -webkit-appearance: none;\n font-family: inherit;\n}\n\nbutton.hr-action-card:hover {\n background: rgba(28, 45, 72, 0.96);\n border-color: rgba(155, 205, 255, 0.62);\n}\n\nbutton.hr-action-card:hover .hr-action-name {\n color: rgba(200, 216, 235, 1);\n}\n\nbutton.hr-action-card:hover .hr-action-details,\nbutton.hr-action-card:hover .hr-action-effect {\n color: rgba(200, 216, 235, 0.92);\n}\n\n.hr-action-title {\n display: flex;\n align-items: center;\n gap: 8px;\n\n color: rgba(200, 216, 235, 0.98);\n font-size: 1.05rem;\n font-weight: 550;\n line-height: 1.15;\n}\n\n.hr-action-name {\n color: rgba(200, 216, 235, 0.98);\n}\n\n.hr-action-effect {\n margin-top: 4px;\n padding-left: 0;\n\n color: rgba(215, 230, 248, 0.86);\n font-size: 1.02rem;\n font-weight: 500;\n line-height: 1.35;\n opacity: 0.95;\n}\n\n.hr-action-details {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 6px 14px;\n\n margin-top: 4px;\n\n color: rgba(215, 230, 248, 0.86);\n font-size: 1.02rem;\n font-weight: 500;\n line-height: 1.35;\n opacity: 0.95;\n}\n\n.hr-action-detail-item {\n display: inline-flex;\n align-items: center;\n white-space: nowrap;\n}\n\n/* 적 행동 */\n.hr-action-card-enemy {\n position: relative;\n\n background: linear-gradient(180deg, #4a0f12 0%, #1a0608 100%);\n border: 1px solid #c24b4b;\n}\n\n.hr-action-card-enemy .hr-action-title {\n color: #d8bfc0;\n}\n\n.hr-action-card-enemy .hr-action-name {\n color: #d8bfc0;\n font-weight: 600;\n letter-spacing: 0.1px;\n padding-right: 22px;\n}\n\n.hr-action-card-enemy::before {\n content: \"⚠\";\n position: absolute;\n top: 8px;\n right: 12px;\n\n font-size: 0.95rem;\n color: #d7899e;\n opacity: 0.65;\n}\n\n/* 아군 행동 */\n.hr-action-card-ally {\n background: rgba(10, 18, 32, 0.92);\n border: 1px solid rgba(120, 190, 160, 0.46);\n border-left: 3px solid rgba(120, 190, 160, 0.72);\n}\n\n.hr-action-card-ally .hr-action-title {\n color: rgba(238, 247, 255, 0.96);\n}\n\n/* 선택 후 표시용 공통 행동 카드 */\n.hr-action-card-resolved-enemy,\n.hr-action-card-resolved-ally,\n.hr-action-card-resolved-player {\n background: rgba(10, 18, 32, 0.92);\n\n border: none;\n border-left: 3px solid rgba(155, 205, 255, 0.72);\n\n color: rgba(215, 230, 248, 0.92);\n}\n\n.hr-action-card-resolved-enemy .hr-action-title,\n.hr-action-card-resolved-ally .hr-action-title,\n.hr-action-card-resolved-player .hr-action-title {\n color: rgba(238, 247, 255, 0.96);\n}\n\n.hr-action-card-resolved-enemy .hr-action-name,\n.hr-action-card-resolved-ally .hr-action-name,\n.hr-action-card-resolved-player .hr-action-name {\n color: rgba(238, 247, 255, 0.96);\n font-weight: 550;\n letter-spacing: 0.1px;\n padding-right: 0;\n}\n\n/* 선택 후 표시용 적 행동 */\n.hr-action-card-resolved-enemy {\n border-left-color: rgba(255, 70, 70, 0.95);\n}\n\n/* 선택 후 표시용 아군 행동 */\n.hr-action-card-resolved-ally {\n border-left-color: rgba(120, 190, 160, 0.82);\n}\n\n/* 선택 후 표시용 플레이어 행동 */\n.hr-action-card-resolved-player {\n border-left-color: rgba(120, 190, 255, 0.98);\n}\n\n.hr-select-panel {\n width: 100%;\n max-width: 1000px;\n height: 180px;\n margin: 20px auto;\n padding: 0;\n box-sizing: border-box;\n\n background: linear-gradient(135deg, rgba(18, 26, 42, 0.96) 0%, rgba(9, 14, 24, 0.98) 100%);\n border: 1px solid rgba(100, 150, 215, 0.42);\n\n font-family: 'SF Pro Text', 'Apple SD Gothic Neo', 'Pretendard', sans-serif;\n}\n\n.hr-select-panel br {\n display: none !important;\n}\n\n.hr-select-panel p {\n margin: 0 !important;\n padding: 0 !important;\n}\n\n.hr-select-row {\n display: flex;\n height: 100%;\n}\n\n.hr-select-col {\n height: 100%;\n box-sizing: border-box;\n border-right: 1px solid rgba(100, 150, 215, 0.34);\n background: rgba(13, 22, 38, 0.82);\n overflow: hidden;\n}\n\n.hr-select-col:last-child {\n border-right: none;\n}\n\n.hr-select-col-heroine {\n flex: 1 1 0;\n width: auto;\n min-width: 0;\n}\n\n.hr-select-col-center {\n flex: 1 1 0;\n width: auto;\n min-width: 0;\n}\n\n.hr-select-col-detail {\n flex: 1 1 0;\n width: auto;\n min-width: 0;\n}\n\n.hr-select-title {\n height: 44px;\n padding: 0;\n box-sizing: border-box;\n\n display: flex;\n align-items: center;\n justify-content: center;\n\n font-size: 17px;\n font-weight: 800;\n color: rgba(155, 205, 255, 0.96);\n\n background: rgba(20, 34, 56, 0.92);\n border-bottom: 1px solid rgba(100, 150, 215, 0.24);\n}\n\n.hr-select-title-add {\n position: relative;\n}\n\n.hr-select-title-text {\n pointer-events: none;\n}\n\n.hr-add-button {\n position: absolute;\n right: 10px;\n top: 50%;\n transform: translateY(-50%);\n\n appearance: none;\n -webkit-appearance: none;\n\n width: 30px;\n height: 30px;\n padding: 0;\n box-sizing: border-box;\n\n border: 1px solid rgba(100, 150, 215, 0.32);\n border-radius: 8px;\n background: rgba(20, 34, 56, 0.92);\n\n color: rgba(215, 230, 248, 0.94);\n font-family: inherit;\n font-size: 17px;\n font-weight: 900;\n line-height: 28px;\n text-align: center;\n\n cursor: pointer;\n}\n\n.hr-add-button:hover {\n background: rgba(38, 60, 90, 0.98);\n color: rgba(238, 247, 255, 1);\n}\n\n.hr-select-list {\n height: calc(100% - 44px);\n padding: 0;\n margin: 0;\n box-sizing: border-box;\n\n overflow-y: auto;\n\n display: flex;\n flex-direction: column;\n align-items: stretch;\n}\n\n.hr-select-item {\n display: block;\n width: 100%;\n height: 36px;\n min-height: 36px;\n padding: 0 14px;\n margin: 0 !important;\n box-sizing: border-box;\n\n border: none;\n border-bottom: 1px solid rgba(100, 150, 215, 0.18);\n border-radius: 0;\n background: rgba(10, 18, 32, 0.92);\n\n color: rgba(215, 230, 248, 0.9);\n font-family: inherit;\n font-size: 15px;\n font-weight: 700;\n line-height: 36px;\n text-align: left;\n\n cursor: pointer;\n}\n\n.hr-select-item:hover {\n background: rgba(28, 45, 72, 0.96);\n color: rgba(238, 247, 255, 0.98);\n}\n\nbutton.hr-select-item {\n appearance: none;\n -webkit-appearance: none;\n}\n\n.hr-center-panel {\n height: 100%;\n display: flex;\n flex-direction: column;\n background: rgba(10, 18, 32, 0.72);\n}\n\n.hr-center-body {\n flex: 1 1 auto;\n min-height: 0;\n padding: 8px 10px;\n box-sizing: border-box;\n\n overflow-y: auto;\n\n display: flex;\n align-items: flex-start;\n justify-content: center;\n align-content: flex-start;\n gap: 8px;\n flex-wrap: wrap;\n\n color: rgba(215, 230, 248, 0.9);\n font-size: 14px;\n font-weight: 700;\n line-height: 1.5;\n\n background: rgba(10, 18, 32, 0.72);\n}\n\n.hr-event-empty {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n\n min-height: 28px;\n padding: 0 12px;\n box-sizing: border-box;\n\n border: 1px solid rgba(100, 150, 215, 0.26);\n border-radius: 8px;\n background: rgba(20, 34, 56, 0.72);\n\n color: rgba(205, 222, 242, 0.82);\n font-size: 13px;\n font-weight: 800;\n line-height: 26px;\n}\n\n.hr-select-detail {\n height: 100%;\n display: flex;\n flex-direction: column;\n background: rgba(10, 18, 32, 0.72);\n}\n\n.hr-select-control-row {\n justify-content: space-between;\n padding: 0 12px;\n background: rgba(20, 34, 56, 0.92);\n}\n\n.hr-info-control-left {\n display: flex;\n align-items: center;\n gap: 8px;\n}\n\n.hr-event-button {\n appearance: none;\n -webkit-appearance: none;\n\n width: 32px;\n height: 32px;\n padding: 0;\n box-sizing: border-box;\n\n display: flex;\n align-items: center;\n justify-content: center;\n\n border: 1px solid rgba(100, 150, 215, 0.32);\n border-radius: 8px;\n background: rgba(20, 34, 56, 0.92);\n\n color: rgba(215, 230, 248, 0.94);\n font-family: inherit;\n font-size: 15px;\n font-weight: 800;\n line-height: 30px;\n text-align: center;\n\n cursor: pointer;\n}\n\n.hr-event-button:hover {\n background: rgba(38, 60, 90, 0.98);\n color: rgba(238, 247, 255, 1);\n}\n\n.hr-toggle-active {\n background: rgba(34, 54, 84, 0.98) !important;\n border-color: rgba(125, 175, 235, 0.58) !important;\n color: rgba(235, 245, 255, 0.98) !important;\n box-shadow: inset 0 0 8px rgba(120, 180, 240, 0.14);\n}\n\n.hr-select-selected-row {\n min-height: 32px;\n height: auto;\n padding: 6px 12px;\n box-sizing: border-box;\n\n display: flex;\n align-items: flex-start;\n justify-content: flex-start;\n flex-wrap: wrap;\n\n font-size: 14px;\n font-weight: 700;\n color: rgba(205, 222, 242, 0.82);\n\n background: rgba(10, 18, 32, 0.72);\n\n line-height: 1.45;\n word-break: keep-all;\n overflow-wrap: anywhere;\n}\n\n.hr-select-detail-fill {\n flex: 1 1 auto;\n min-height: 0;\n background: rgba(10, 18, 32, 0.72);\n}\n\n.hr-info-radio {\n display: none !important;\n}\n\n.hr-info-drawer {\n position: fixed;\n top: 0;\n right: -520px;\n\n width: 520px;\n max-width: 92vw;\n height: 100vh;\n\n box-sizing: border-box;\n padding: 18px;\n\n background: linear-gradient(135deg, rgba(18, 26, 42, 0.98) 0%, rgba(9, 14, 24, 0.99) 100%);\n border-left: 2px solid rgba(100, 150, 215, 0.52);\n box-shadow: -10px 0 28px rgba(0, 0, 0, 0.38);\n\n color: rgba(215, 230, 248, 0.92);\n font-family: 'SF Pro Text', 'Apple SD Gothic Neo', 'Pretendard', sans-serif;\n\n z-index: 22000;\n transition: right 0.22s ease;\n overflow: hidden;\n}\n\n#hr-info-selected:checked ~ .hr-info-drawer-selected {\n right: 0;\n}\n\n.hr-info-header {\n position: relative;\n height: 44px;\n display: flex;\n align-items: center;\n justify-content: center;\n\n border-bottom: 1px solid rgba(100, 150, 215, 0.28);\n margin-bottom: 12px;\n}\n\n.hr-info-title {\n font-size: 18px;\n font-weight: 900;\n color: rgba(155, 205, 255, 0.98);\n}\n\n.hr-info-close {\n position: absolute;\n right: 0;\n top: 8px;\n\n width: 28px;\n height: 28px;\n\n display: flex;\n align-items: center;\n justify-content: center;\n\n border: 1px solid rgba(100, 150, 215, 0.35);\n border-radius: 8px;\n background: rgba(20, 34, 56, 0.92);\n\n color: rgba(215, 230, 248, 0.94);\n cursor: pointer;\n}\n\n.hr-info-content {\n height: calc(100vh - 86px);\n overflow-y: auto;\n padding-right: 4px;\n}\n\n.hr-info-empty {\n padding: 12px;\n color: rgba(205, 222, 242, 0.62);\n font-size: 14px;\n font-weight: 700;\n}\n\n.hr-info-body {\n padding: 10px;\n font-size: 14px;\n font-weight: 600;\n line-height: 1.55;\n color: rgba(215, 230, 248, 0.9);\n}\n\n.hr-profile-heading {\n font-size: 21px;\n font-weight: 700;\n line-height: 1.55;\n color: rgba(255, 210, 128, 0.96);\n}\n\n.hr-profile-item {\n font-size: 16px;\n font-weight: 700;\n line-height: 1.55;\n color: rgba(215, 230, 248, 0.88);\n}\n\n.hr-profile-gap {\n height: 10px;\n}\n\n.hr-first-panel {\n margin: 0;\n border: 1px solid rgba(100, 150, 215, 0.24);\n background: rgba(10, 18, 32, 0.58);\n box-sizing: border-box;\n}\n\n.hr-first-row {\n min-height: 42px;\n padding: 8px 12px;\n box-sizing: border-box;\n\n display: flex;\n align-items: center;\n gap: 10px;\n flex-wrap: nowrap;\n}\n\n.hr-first-label {\n flex: 0 0 auto;\n\n font-size: 14px;\n font-weight: 900;\n color: rgba(155, 205, 255, 0.96);\n\n white-space: nowrap;\n}\n\n.hr-first-buttons {\n flex: 1 1 auto;\n\n display: flex;\n align-items: center;\n gap: 8px;\n flex-wrap: nowrap;\n\n overflow-x: auto;\n}\n\n.hr-first-button {\n appearance: none;\n -webkit-appearance: none;\n\n height: 30px;\n padding: 0 10px;\n box-sizing: border-box;\n\n border: 1px solid rgba(100, 150, 215, 0.32);\n border-radius: 8px;\n background: rgba(20, 34, 56, 0.92);\n\n color: rgba(215, 230, 248, 0.94);\n font-family: inherit;\n font-size: 13px;\n font-weight: 800;\n line-height: 28px;\n text-align: center;\n\n cursor: pointer;\n white-space: nowrap;\n}\n\n.hr-first-button:hover {\n background: rgba(38, 60, 90, 0.98);\n color: rgba(238, 247, 255, 1);\n}\n\n/* 모바일 대응 */\n@media (max-width: 640px) {\n\n .hr-select-panel {\n height: auto;\n min-height: 220px;\n }\n\n .hr-select-control-row {\n height: auto;\n min-height: 72px;\n padding: 4px 6px;\n align-items: flex-start;\n gap: 4px;\n }\n\n .hr-info-control-left {\n flex-wrap: wrap;\n gap: 3px;\n max-width: calc(100% - 34px);\n }\n\n .hr-event-button {\n width: 28px;\n height: 28px;\n font-size: 13px;\n line-height: 26px;\n border-radius: 6px;\n flex: 0 0 auto;\n }\n}\n</style>",
"trigger": [
{
"comment": "",
"type": "start",
"conditions": [],
"effect": [
{
"type": "triggerlua",
"code": "-- ==========================================\n-- 공용 모듈\n-- ==========================================\nfunction trim(s)\n return (s:gsub(\"^%s+\", \"\"):gsub(\"%s+$\", \"\"))\nend\n\nfunction clamp(v, min, max)\n if v < min then return min end\n if v > max then return max end\n return v\nend\n\nfunction splitLiteral(str, sep)\n local t = {}\n\n if not str or str == \"\" then\n return t\n end\n\n local start = 1\n\n while true do\n local i, j = string.find(str, sep, start, true)\n if not i then\n table.insert(t, trim(string.sub(str, start)))\n break\n end\n\n table.insert(t, trim(string.sub(str, start, i - 1)))\n start = j + 1\n end\n\n return t\nend\n\nfunction appendSeparated(fullValue, addValue, separator)\n if not addValue or addValue == \"\" then\n return fullValue\n end\n\n if not fullValue or fullValue == \"\" then\n return addValue\n end\n\n return fullValue .. separator .. addValue\nend\n\nfunction extractInt(s)\n if not s then return 0 end\n local m = tostring(s):match(\"[-+]?%d+\")\n return tonumber(m) or 0\nend\n\nfunction formatFloat(v)\n local s = string.format(\"%.1f\", roundToTenth(tonumber(v) or 0))\n s = s:gsub(\"%.0$\", \"\")\n if s == \"-0\" then s = \"0\" end\n return s\nend\n\nfunction roundHalfUp(v)\n v = tonumber(v) or 0\n\n if v >= 0 then\n return math.floor(v + 0.5)\n else\n return math.ceil(v - 0.5)\n end\nend\n\nfunction roundToTenth(v)\n v = tonumber(v) or 0\n\n local out\n\n if v >= 0 then\n out = math.floor(v * 10 + 0.5) / 10\n else\n out = math.ceil(v * 10 - 0.5) / 10\n end\n\n if out == 0 then\n return 0\n end\n\n if out == math.floor(out) then\n return math.floor(out)\n end\n\n return out\nend\n\nfunction shuffleArray(t)\n for i = #t, 2, -1 do\n local j = math.random(i)\n t[i], t[j] = t[j], t[i]\n end\nend\n\nfunction formatSignedNumber(value)\n value = tonumber(value) or 0\n\n if value > 0 then\n return \"+\" .. tostring(value)\n end\n\n return tostring(value)\nend\n\nfunction safeGet(triggerId, key, default)\n local val = getChatVar(triggerId, key)\n if val == nil or val == \"null\" or val == \"nil\" then return default end\n return val\nend\n\nfunction setTrackedChatVar(triggerId, changedVars, key, value)\n local prev = safeGet(triggerId, key, \"\")\n\n if changedVars and tostring(prev) ~= tostring(value) then\n changedVars[key] = value\n end\n\n setChatVar(triggerId, key, value)\nend\n\nfunction pushChoiceInput(triggerId, text)\n text = tostring(text or \"\")\n\n local cl = getChatLength(triggerId)\n\n if cl > 0 then\n local last = getChat(triggerId, cl - 1)\n\n if last and last.role == \"user\" and type(last.data) == \"string\" then\n setChat(triggerId, cl - 1, text)\n return\n end\n end\n\n addChat(triggerId, \"user\", text)\nend\n\nfunction getLanguage(triggerId)\n return safeGet(triggerId, \"hr_Language\", \"\")\nend\n\n-- ==========================================\n-- 변수\n-- ==========================================\nlocal hrContext = {\n varName = \"hr_Context\",\n phase = 1,\n}\n\nlocal hrSessionStatus = {\n varName = \"hr_Session_Status\",\n turn = 1,\n\n sexualCount = 2,\n title = 3,\n objective = 4,\n}\n\n-- ==========================================\n-- 상수\n-- ==========================================\nlocal constHeroine = {\n codePrefix = \"H\",\n listKey = \"hr_Heroine_List\",\n codeKey = \"hr_Heroine_Code\",\n searchKey = \"hr_Heroine_Search\",\n displayKey = \"hr_Heroine_Display\",\n}\n\nlocal constFlag = {\n no = \"0\",\n yes = \"1\",\n}\n\nlocal constPhase = {\n none = 0,\n\n free = 1,\n createRoute = 2,\n createHScene = 3,\n addSexualTrait = 4,\n\n event = 11,\n event_Resolution = 12,\n event_Incapacitated = 13,\n\n masturbation = 21,\n masturbation_End = 22,\n\n hScene_Event = 102,\n hScene_End = 103,\n}\n\nlocal constChoice = {\n none = 0,\n\n choice_EventDevelop = 1,\n choice_CreateRoute = 2,\n choice_CreateHScene = 3,\n\n choice_Masturbation = 21,\n\n choice_HScene_Trigger = 101,\n choice_HScene_Event = 102,\n choice_HScene_After = 103,\n}\n\n\nlocal constFirstExperience = {\n varName = \"hr_FirstExperiences\",\n defaultValue = \"0000\",\n length = 4,\n\n kiss = 1,\n oral = 2,\n vaginal = 3,\n anal = 4,\n}\n\nlocal constCycle = {\n safe = 1,\n menstruation = 2,\n\n fertile_m5 = 3,\n fertile_m4 = 4,\n fertile_m3 = 5,\n fertile_m2 = 6,\n fertile_m1 = 7,\n\n ovulation = 8,\n\n fertile_p1 = 9,\n\n post_fertile = 10,\n\n pregnant = 20,\n pending = 21,\n}\n\nlocal constArousalResponse = {\n none = 0,\n pure = 1,\n cool = 2,\n tsundere = 3,\n tyrant = 4,\n bravado = 5,\n timid = 6,\n denpa = 7,\n broken = 8,\n hedonist = 9,\n}\n\nlocal constArousalState = {\n none = 0,\n minimal = 1,\n low = 2,\n subtle = 3,\n mild = 4,\n clear = 5,\n sustained = 6,\n strong = 7,\n veryStrong = 8,\n overwhelming = 9,\n peak = 10,\n}\n\nlocal constOutcome = {\n none = 0,\n\n event_Favorable = 1,\n event_Setback = 2,\n event_Molestation = 3,\n\n event_Resolution = 11,\n event_Closure = 12,\n event_Incapacitated = 13,\n}\n\nlocal constRecordFlag = {\n none = 0,\n\n event_Start = 1,\n\n hScene_Incapacitation = 11,\n hScene_Transition = 12,\n hScene_Progress = 13,\n}\n\nlocal constMemoryField = {\n heroineCode = 1,\n typeCode = 2,\n date = 3,\n milestone = 4,\n content = 5,\n extra = 6,\n}\n\nlocal constMemoryType = {\n firstExperience = 1,\n pregnancyStart = 2,\n pregnancyEnd = 3,\n eventSummary = 4,\n\n sexualEffect_Add = 11,\n sexualEffect_Remove = 12,\n}\n\n-- ==========================================\n-- 공용 첫경험\n-- ==========================================\nfunction normalizeFirstExperienceFlags(value)\n value = trim(tostring(value or \"\"))\n\n if value == \"\" or value == \"nil\" or value == \"null\" then\n return \"\"\n end\n\n local out = {}\n\n for i = 1, constFirstExperience.length do\n local ch = value:sub(i, i)\n\n if ch == constFlag.yes then\n out[i] = constFlag.yes\n else\n out[i] = constFlag.no\n end\n end\n\n return table.concat(out, \"\")\nend\n\nfunction isFirstExperienceFlagYes(value, position)\n position = tonumber(position) or 0\n\n if position < 1 or position > constFirstExperience.length then\n return false\n end\n\n local flags = normalizeFirstExperienceFlags(value)\n\n if flags == \"\" then\n return false\n end\n\n return flags:sub(position, position) == constFlag.yes\nend\n\nfunction getFirstExperienceSlotList(triggerId)\n return splitLiteral(safeGet(triggerId, constFirstExperience.varName, \"\"), \"|\")\nend\n\nfunction getFirstExperienceSlotFromList(slotList, index)\n index = tonumber(index) or 0\n\n if index < 1 then\n return \"\"\n end\n\n return normalizeFirstExperienceFlags(slotList[index] or \"\")\nend\n\nfunction getFirstExperienceSlot(triggerId, index)\n local slotList = getFirstExperienceSlotList(triggerId)\n return getFirstExperienceSlotFromList(slotList, index)\nend\n\nfunction saveFirstExperienceSlotList(triggerId, slotList, changedVars)\n local lastUsed = 0\n\n for i = 1, #slotList do\n if normalizeFirstExperienceFlags(slotList[i] or \"\") ~= \"\" then\n lastUsed = i\n end\n end\n\n local out = {}\n\n for i = 1, lastUsed do\n out[i] = normalizeFirstExperienceFlags(slotList[i] or \"\")\n end\n\n local newValue = table.concat(out, \"|\")\n\n if changedVars then\n setTrackedChatVar(triggerId, changedVars, constFirstExperience.varName, newValue)\n else\n setChatVar(triggerId, constFirstExperience.varName, newValue)\n end\nend\n\nfunction setFirstExperienceSlot(triggerId, index, flags, changedVars)\n index = tonumber(index) or 0\n\n if index < 1 then\n return\n end\n\n flags = normalizeFirstExperienceFlags(flags)\n\n if flags == \"\" then\n flags = constFirstExperience.defaultValue\n end\n\n local slotList = getFirstExperienceSlotList(triggerId)\n\n for i = #slotList + 1, index do\n slotList[i] = \"\"\n end\n\n slotList[index] = flags\n saveFirstExperienceSlotList(triggerId, slotList, changedVars)\nend\n\nfunction getFirstExperienceFlagByPosition(triggerId, index, position)\n local flags = getFirstExperienceSlot(triggerId, index)\n\n if flags == \"\" then\n return \"\"\n end\n\n if isFirstExperienceFlagYes(flags, position) then\n return constFlag.yes\n end\n\n return constFlag.no\nend\n\nfunction setFirstExperienceFlagByPosition(triggerId, index, position, value, changedVars)\n index = tonumber(index) or 0\n position = tonumber(position) or 0\n\n if index < 1 or position < 1 or position > constFirstExperience.length then\n return\n end\n\n local slotList = getFirstExperienceSlotList(triggerId)\n\n for i = #slotList + 1, index do\n slotList[i] = \"\"\n end\n\n local flags = normalizeFirstExperienceFlags(slotList[index] or \"\")\n\n if flags == \"\" then\n flags = constFirstExperience.defaultValue\n end\n\n local out = {}\n\n for i = 1, constFirstExperience.length do\n out[i] = flags:sub(i, i)\n end\n\n if tostring(value or \"\") == constFlag.yes then\n out[position] = constFlag.yes\n else\n out[position] = constFlag.no\n end\n\n slotList[index] = table.concat(out, \"\")\n saveFirstExperienceSlotList(triggerId, slotList, changedVars)\nend\n\n-- ==========================================\n-- 공용 듀얼 플래그\n--\n-- 슬롯 형식:\n-- \"\" -> current 없음 / next 없음\n-- \"1\" -> current=1 / next 없음\n-- \"§1\" -> current 없음 / next=1\n-- \"1§2\" -> current=1 / next=2\n--\n-- position은 1부터 시작\n-- isNext == true -> next값 get/set\n-- isNext == false -> current값 get/set\n-- ==========================================\nfunction parseDualFlagSlot(slot)\n slot = tostring(slot or \"\")\n\n if slot == \"\" then\n return \"\", \"\"\n end\n\n local currentValue, nextValue = slot:match(\"^(.-)§(.*)$\")\n\n if currentValue == nil then\n currentValue = slot\n nextValue = \"\"\n end\n\n if currentValue == \"nil\" or currentValue == \"null\" then\n currentValue = \"\"\n end\n\n if nextValue == \"nil\" or nextValue == \"null\" then\n nextValue = \"\"\n end\n\n return tostring(currentValue or \"\"), tostring(nextValue or \"\")\nend\n\nfunction buildDualFlagSlot(currentValue, nextValue)\n currentValue = tostring(currentValue or \"\")\n nextValue = tostring(nextValue or \"\")\n\n if currentValue == \"nil\" or currentValue == \"null\" then\n currentValue = \"\"\n end\n\n if nextValue == \"nil\" or nextValue == \"null\" then\n nextValue = \"\"\n end\n\n if currentValue ~= \"\" then\n currentValue = trim(currentValue)\n end\n\n if nextValue ~= \"\" then\n nextValue = trim(nextValue)\n end\n\n if currentValue == nextValue then\n nextValue = \"\"\n end\n\n if currentValue == \"\" and nextValue == \"\" then\n return \"\"\n elseif nextValue ~= \"\" then\n return currentValue .. \"§\" .. nextValue\n else\n return currentValue\n end\nend\n\nfunction getDualFlagSlotList(triggerId, varName)\n local raw = safeGet(triggerId, varName, \"\")\n local slotList = {}\n\n if raw ~= \"\" then\n for token in (raw .. \"|\"):gmatch(\"(.-)|\") do\n table.insert(slotList, token or \"\")\n end\n end\n\n return slotList\nend\n\nfunction getResolvedDualFlag(triggerId, varName, position, defaultValue)\n local nextValue = getDualFlag(triggerId, varName, position, true)\n if nextValue ~= nil and tostring(nextValue) ~= \"\" then\n return tostring(nextValue)\n end\n\n local currentValue = getDualFlag(triggerId, varName, position, false)\n if currentValue ~= nil and tostring(currentValue) ~= \"\" then\n return tostring(currentValue)\n end\n\n return tostring(defaultValue or \"\")\nend\n\nfunction getResolvedDualFlagFromSlotList(slotList, position, defaultValue)\n local nextValue = getDualFlagFromSlotList(slotList, position, true)\n if nextValue ~= nil and tostring(nextValue) ~= \"\" then\n return tostring(nextValue)\n end\n\n local currentValue = getDualFlagFromSlotList(slotList, position, false)\n if currentValue ~= nil and tostring(currentValue) ~= \"\" then\n return tostring(currentValue)\n end\n\n return tostring(defaultValue or \"\")\nend\n\nfunction setDualFlagInSlotList(slotList, position, value, isNext)\n position = tonumber(position) or 0\n if position < 1 then return end\n\n for i = #slotList + 1, position do\n slotList[i] = \"\"\n end\n\n local currentValue, nextValue = parseDualFlagSlot(slotList[position] or \"\")\n\n value = tostring(value or \"\")\n if value == \"nil\" or value == \"null\" then\n value = \"\"\n end\n if value ~= \"\" then\n value = trim(value)\n end\n\n if isNext == true then\n nextValue = value\n else\n currentValue = value\n end\n\n slotList[position] = buildDualFlagSlot(currentValue, nextValue)\nend\n\nfunction batchSetDualFlags(triggerId, varName, updates)\n local slotList = getDualFlagSlotList(triggerId, varName)\n\n for i = 1, #updates do\n local u = updates[i]\n setDualFlagInSlotList(slotList, u.position, u.value, u.isNext)\n end\n\n saveDualFlagSlotList(triggerId, varName, slotList)\nend\n\nfunction saveDualFlagSlotList(triggerId, varName, slotList)\n local lastUsed = 0\n\n for i = 1, #slotList do\n if tostring(slotList[i] or \"\") ~= \"\" then\n lastUsed = i\n end\n end\n\n if lastUsed <= 0 then\n setChatVar(triggerId, varName, \"\")\n return\n end\n\n local out = {}\n\n for i = 1, lastUsed do\n out[i] = tostring(slotList[i] or \"\")\n end\n\n setChatVar(triggerId, varName, table.concat(out, \"|\"))\nend\n\nfunction getDualFlag(triggerId, varName, position, isNext)\n position = tonumber(position) or 0\n\n if position < 1 then\n return \"\"\n end\n\n local slotList = getDualFlagSlotList(triggerId, varName)\n local slot = tostring(slotList[position] or \"\")\n\n if slot == \"\" then\n return \"\"\n end\n\n local currentValue, nextValue = parseDualFlagSlot(slot)\n\n if isNext == true then\n return tostring(nextValue or \"\")\n else\n return tostring(currentValue or \"\")\n end\nend\n\nfunction setDualFlag(triggerId, varName, position, value, isNext)\n position = tonumber(position) or 0\n\n if position < 1 then\n return\n end\n\n local slotList = getDualFlagSlotList(triggerId, varName)\n setDualFlagInSlotList(slotList, position, value, isNext)\n saveDualFlagSlotList(triggerId, varName, slotList)\nend\n\nfunction applyNextDualFlagToCurrent(triggerId, varName)\n local before = safeGet(triggerId, varName, \"\")\n local slotList = getDualFlagSlotList(triggerId, varName)\n\n if #slotList <= 0 then\n return false\n end\n\n for i = 1, #slotList do\n local currentValue, nextValue = parseDualFlagSlot(slotList[i] or \"\")\n\n if nextValue ~= \"\" then\n currentValue = nextValue\n end\n\n slotList[i] = buildDualFlagSlot(currentValue, \"\")\n end\n\n saveDualFlagSlotList(triggerId, varName, slotList)\n\n local after = safeGet(triggerId, varName, \"\")\n return before ~= after\nend\n\nfunction clearNextDualFlag(triggerId, varName)\n local slotList = getDualFlagSlotList(triggerId, varName)\n\n if #slotList <= 0 then\n return\n end\n\n for i = 1, #slotList do\n local currentValue, _ = parseDualFlagSlot(slotList[i] or \"\")\n\n if currentValue ~= \"\" then\n slotList[i] = buildDualFlagSlot(currentValue, \"\")\n else\n slotList[i] = \"\"\n end\n end\n\n saveDualFlagSlotList(triggerId, varName, slotList)\nend\n\nfunction getDualFlagFromSlotList(slotList, position, isNext)\n position = tonumber(position) or 0\n\n if position < 1 then\n return \"\"\n end\n\n local slot = tostring(slotList[position] or \"\")\n\n if slot == \"\" then\n return \"\"\n end\n\n local currentValue, nextValue = parseDualFlagSlot(slot)\n\n if isNext == true then\n return tostring(nextValue or \"\")\n else\n return tostring(currentValue or \"\")\n end\nend\n\nfunction loadDualFlagCurrentMap(triggerId, def)\n local slotList = getDualFlagSlotList(triggerId, def.varName)\n local out = {}\n\n for key, position in pairs(def) do\n if key ~= \"varName\" then\n out[key] = tonumber(getDualFlagFromSlotList(slotList, position, false)) or 0\n end\n end\n\n return out\nend\n\nfunction loadDualFlagNextMap(triggerId, def)\n local slotList = getDualFlagSlotList(triggerId, def.varName)\n local out = {}\n\n for key, position in pairs(def) do\n if key ~= \"varName\" then\n out[key] = tonumber(getResolvedDualFlagFromSlotList(slotList, position, 0)) or 0\n end\n end\n\n return out\nend\n\nfunction batchResetAndSetNextDualFlags(triggerId, varName, updates)\n local slotList = getDualFlagSlotList(triggerId, varName)\n\n -- ==========================================\n -- 기존 next 제거, current만 유지\n -- ==========================================\n for i = 1, #slotList do\n local currentValue, _ = parseDualFlagSlot(slotList[i] or \"\")\n\n if currentValue ~= \"\" then\n slotList[i] = buildDualFlagSlot(currentValue, \"\")\n else\n slotList[i] = \"\"\n end\n end\n\n -- ==========================================\n -- 전달된 값만 next로 설정\n -- ==========================================\n for i = 1, #(updates or {}) do\n local u = updates[i]\n setDualFlagInSlotList(slotList, u.position, u.value, true)\n end\n\n saveDualFlagSlotList(triggerId, varName, slotList)\nend\n\n-- ==========================================\n-- 공용 날짜\n-- ==========================================\nfunction isLeapYear(year)\n year = tonumber(year) or 0\n\n return (\n year % 4 == 0\n and year % 100 ~= 0\n ) or year % 400 == 0\nend\n\nfunction getDaysInMonth(year, month)\n year = tonumber(year) or 0\n month = tonumber(month) or 0\n\n local days = {\n [1] = 31,\n [2] = 28,\n [3] = 31,\n [4] = 30,\n [5] = 31,\n [6] = 30,\n [7] = 31,\n [8] = 31,\n [9] = 30,\n [10] = 31,\n [11] = 30,\n [12] = 31\n }\n\n if month == 2 and isLeapYear(year) then\n return 29\n end\n\n return days[month] or 0\nend\n\nfunction parseDateText(dateText)\n dateText = trim(tostring(dateText or \"\"))\n\n if dateText == \"\" then\n return nil\n end\n\n local y, m, d\n\n -- 형식 1: 연도 + MM + DD\n -- 예) 30511, 500101, 9001225, 20260511\n if dateText:match(\"^%d+$\") then\n if #dateText < 5 then\n return nil\n end\n\n y = tonumber(dateText:sub(1, -5))\n m = tonumber(dateText:sub(-4, -3))\n d = tonumber(dateText:sub(-2))\n\n else\n -- 형식 2: 연도-MM-DD / 연도.MM.DD / 연도/MM/DD\n -- 예) 3-05-11, 50.01.01, 900/12/25\n local sep1, sep2\n y, sep1, m, sep2, d =\n dateText:match(\"^%s*(%d+)%s*([./-])%s*(%d%d)%s*([./-])%s*(%d%d)%s*$\")\n\n if not y or sep1 ~= sep2 then\n return nil\n end\n\n y = tonumber(y)\n m = tonumber(m)\n d = tonumber(d)\n end\n\n if not y or not m or not d then\n return nil\n end\n\n if y < 1 then\n return nil\n end\n\n if m < 1 or m > 12 then\n return nil\n end\n\n local maxDay = getDaysInMonth(y, m)\n if d < 1 or d > maxDay then\n return nil\n end\n\n return {\n year = y,\n month = m,\n day = d\n }\nend\n\nfunction formatDateText(dateTable)\n if not dateTable then\n return \"\"\n end\n\n return tostring(dateTable.year)\n .. string.format(\"%02d%02d\", dateTable.month, dateTable.day)\nend\n\nfunction addDaysToDate(dateTable, diffDays)\n if not dateTable then\n return nil\n end\n\n local y = tonumber(dateTable.year)\n local m = tonumber(dateTable.month)\n local d = tonumber(dateTable.day)\n local diff = math.floor(tonumber(diffDays) or 0)\n\n if not y or not m or not d then\n return nil\n end\n\n if y < 1 or m < 1 or m > 12 then\n return nil\n end\n\n if d < 1 or d > getDaysInMonth(y, m) then\n return nil\n end\n\n while diff > 0 do\n local maxDay = getDaysInMonth(y, m)\n local remainDaysInMonth = maxDay - d\n\n if diff <= remainDaysInMonth then\n d = d + diff\n diff = 0\n else\n diff = diff - remainDaysInMonth - 1\n d = 1\n m = m + 1\n\n if m > 12 then\n m = 1\n y = y + 1\n end\n end\n end\n\n while diff < 0 do\n local backDays = -diff\n\n if backDays < d then\n d = d - backDays\n diff = 0\n else\n diff = diff + d\n m = m - 1\n\n if m < 1 then\n m = 12\n y = y - 1\n\n if y < 1 then\n return nil\n end\n end\n\n d = getDaysInMonth(y, m)\n end\n end\n\n return {\n year = y,\n month = m,\n day = d\n }\nend\n\nfunction extractLastBracketDateText(text)\n if type(text) ~= \"string\" or text == \"\" then\n return \"\"\n end\n\n -- ==========================================\n -- 날짜 추출에서 제외할 기록 블록 제거\n -- 상태창 태그 전체를 제거하면 안 되니까,\n -- 날짜 기록용 태그만 골라서 제거함\n -- ==========================================\n local scanText = text\n\n scanText = scanText:gsub(\n \"<Record First[^>]*>[%s%S]-</Record First[^>]*>\",\n \"\"\n )\n\n scanText = scanText:gsub(\n \"<Event Summary[^>]*>[%s%S]-</Event Summary>\",\n \"\"\n )\n\n scanText = scanText:gsub(\n \"<Pregnancy Start[^>]*>[%s%S]-</Pregnancy Start[^>]*>\",\n \"\"\n )\n\n scanText = scanText:gsub(\n \"<Pregnancy End[^>]*>[%s%S]-</Pregnancy End[^>]*>\",\n \"\"\n )\n\n local lastDate = \"\"\n\n for block in scanText:gmatch(\"%b[]\") do\n local inner = block:sub(2, -2)\n local searchStart = 1\n\n while true do\n local si, ei, y, sep1, m, sep2, d =\n inner:find(\"(%d+)%s*([./-])%s*(%d%d)%s*([./-])%s*(%d%d)\", searchStart)\n\n local ci, ce, compact =\n inner:find(\"(%d%d%d%d%d+)\", searchStart)\n\n if not si and not ci then\n break\n end\n\n local rawDate = \"\"\n local nextStart = nil\n\n -- 같은 위치면 구분자 날짜를 우선함\n if si and (not ci or si <= ci) then\n if sep1 == sep2 then\n rawDate = y .. sep1 .. m .. sep2 .. d\n end\n\n nextStart = ei + 1\n else\n rawDate = compact\n nextStart = ce + 1\n end\n\n local dt = parseDateText(rawDate)\n if dt then\n lastDate = formatDateText(dt)\n end\n\n searchStart = nextStart\n end\n end\n\n return lastDate\nend\n\nfunction dateToSerialDay(dateTable)\n if not dateTable then\n return nil\n end\n\n local y = tonumber(dateTable.year)\n local m = tonumber(dateTable.month)\n local d = tonumber(dateTable.day)\n\n if not y or not m or not d then\n return nil\n end\n\n if y < 1 or m < 1 or m > 12 then\n return nil\n end\n\n if d < 1 or d > getDaysInMonth(y, m) then\n return nil\n end\n\n local yy = y - 1\n\n local days =\n yy * 365\n + math.floor(yy / 4)\n - math.floor(yy / 100)\n + math.floor(yy / 400)\n\n for month = 1, m - 1 do\n days = days + getDaysInMonth(y, month)\n end\n\n return days + d\nend\n\nfunction diffDaysBetweenDateTables(fromDateTable, toDateTable)\n local fromSerial = dateToSerialDay(fromDateTable)\n local toSerial = dateToSerialDay(toDateTable)\n\n if not fromSerial or not toSerial then\n return nil\n end\n\n return toSerial - fromSerial\nend\n\nfunction formatDateTextWithHyphen(dateText)\n dateText = trim(tostring(dateText or \"\"))\n\n if dateText == \"\" then\n return \"\"\n end\n\n local dateTable = parseDateText(dateText)\n\n if not dateTable then\n return dateText\n end\n\n return tostring(dateTable.year) ..\n \"-\" .. string.format(\"%02d\", dateTable.month) ..\n \"-\" .. string.format(\"%02d\", dateTable.day)\nend\n\n-- ==========================================\n-- 페이즈\n-- ==========================================\nfunction setPhase(triggerId, curPhase, applyEvent, forceSelectedName)\n if curPhase ~= constPhase.createRoute and curPhase ~= constPhase.createHScene and curPhase ~= constPhase.addSexualTrait then\n setDualFlag(triggerId, hrContext.varName, hrContext.phase, tostring(curPhase), true)\n else\n setDualFlag(triggerId, hrContext.varName, hrContext.phase, tostring(constPhase.free), true)\n end\n \n local selectedName = trim(safeGet(triggerId, \"hr_Selected_Name\", \"\"))\n forceSelectedName = trim(tostring(forceSelectedName or \"\"))\n if forceSelectedName ~= \"\" then\n selectedName = forceSelectedName\n end\n\n local mainHeroine = trim(safeGet(triggerId, \"hr_Event_Main\", \"\"))\n\n local eventButtonList = {\n { key = \"free\", trigger = \"selectEventFree\", title = \"자유 모드\", icon = \"✍️\" },\n\n { key = \"resolveSituation\", trigger = \"selectResolveSituation\", title = \"상황 종결\", icon = \"🚩\" },\n { key = \"dailySkip\", trigger = \"selectDailySkip\", title = \"일상 스킵\", icon = \"⏳\" },\n\n { key = \"hookHeroine\", trigger = \"selectHookHeroine\", title = \"사건 발생\", icon = \"💡\" },\n { key = \"hookRandom\", trigger = \"selectHookRandom\", title = \"랜덤 사건 발생\", icon = \"🎲\" },\n\n { key = \"createRoute\", trigger = \"selectCreateRoute\", title = \"선택지 생성\", icon = \"🧭\" },\n { key = \"createCustomRoute\", trigger = \"selectCreateCustomRoute\", title = \"커스텀 선택지 생성\", icon = \"🎨\" },\n\n { key = \"createHScene\", trigger = \"selectCreateHScene\", title = \"H씬 선택지 생성\", icon = \"🔞\" },\n { key = \"createCustomHScene\", trigger = \"selectCreateCustomHScene\", title = \"커스텀 H씬 선택지 생성\", icon = \"💟\" },\n\n { key = \"event\", trigger = \"selectEventStart\", title = \"이벤트 시작\", icon = \"▶️\" },\n { key = \"randomEvent\", trigger = \"selectRandomEvent\", title = \"랜덤 이벤트\", icon = \"❓\", attrName = \"risu-btn\" },\n\n { key = \"masturbation\", trigger = \"selectEventMasturbation\", title = \"자위\", icon = \"💦\" },\n { key = \"sexualTrait\", trigger = \"selectAddSexualTrait\", title = \"성적 특성 추가\", icon = \"❣️\" },\n }\n\n local selectedEvent = \"\"\n local eventMessage = \"\"\n local choiceFlag = constChoice.none\n local sessionSaveFlag = constFlag.no\n local arousalCheckFlag = constFlag.yes\n local violationTitle = \"\"\n local recordFlag = constRecordFlag.none\n local summaryFlag = constFlag.no\n local sexualEffectFlag = constFlag.no\n\n local eventOutcomeLines = {}\n local eventButtonEnabled = {}\n\n local function getEventLabel(key)\n for _, buttonInfo in ipairs(eventButtonList) do\n if buttonInfo.key == key then\n return buttonInfo.icon .. \" \" .. buttonInfo.title\n end\n end\n\n return \"\"\n end\n\n local function getSexualTraitFlag()\n local flag = constFlag.no\n local sexualCount = tonumber(getResolvedDualFlag(triggerId, hrSessionStatus.varName, hrSessionStatus.sexualCount, 0)) or 0\n local scopeText = \"\"\n\n if (curPhase == constPhase.event_Resolution or curPhase == constPhase.event_Incapacitated) and sexualCount >= 1 then\n scopeText = getLatestEventRecordSummary(triggerId, \"Event Start\")\n flag = constFlag.yes\n elseif curPhase == constPhase.masturbation_End then\n scopeText = \"Start of the current masturbation\"\n flag = constFlag.yes\n elseif curPhase == constPhase.hScene_End then\n scopeText = getLatestEventRecordSummary(triggerId, \"Incapacitation\")\n flag = constFlag.yes\n elseif curPhase == constPhase.addSexualTrait then\n scopeText = \"Start of the current event\"\n flag = constFlag.yes\n end\n\n if flag == constFlag.yes then\n setChatVar(triggerId, \"hr_SexualTrait_Owned\", buildSexualTraitOwnedText(triggerId))\n else\n setChatVar(triggerId, \"hr_SexualTrait_Owned\", \"\")\n end\n\n setChatVar(triggerId, \"hr_SexualTrait_Scope\", scopeText)\n\n return flag\n end\n\n local routeChoiceType = {\n event = 1,\n incapacitated = 2,\n\n trapped = 4,\n stranded = 5,\n bargained = 6,\n }\n\n local function addRouteChoiceFieldLines(heroineName, setupLine, typeOptions)\n typeOptions = typeOptions or {}\n\n table.insert(eventOutcomeLines, setupLine)\n\n local routeTypeList = {\n {\n id = routeChoiceType.event,\n key = \"Event\",\n line = \" Event: \" .. heroineName .. \" becomes involved in a developing active situation\",\n },\n {\n id = routeChoiceType.incapacitated,\n key = \"Incapacitated\",\n line = \" Incapacitated: \" .. heroineName .. \" becomes incapacitated\",\n },\n {\n id = routeChoiceType.trapped,\n key = \"Trapped\",\n line = \" Trapped: \" .. heroineName .. \" is drawn into a trap, deception, or inescapable situation\",\n },\n {\n id = routeChoiceType.stranded,\n key = \"Stranded\",\n line = \" Stranded: \" .. heroineName .. \" is stranded away from safe help in hostile or dangerous surroundings\",\n },\n {\n id = routeChoiceType.bargained,\n key = \"Bargained\",\n line = \" Bargained: \" .. heroineName .. \" is pushed into an exploitative bargain, debt, or transactional demand\",\n },\n }\n\n local typeNames = {}\n\n for _, typeInfo in ipairs(routeTypeList) do\n if typeOptions[typeInfo.id] then\n table.insert(typeNames, typeInfo.key)\n end\n end\n\n if #typeNames <= 0 then\n return\n end\n\n if #typeNames == 1 then\n table.insert(eventOutcomeLines, \"- Type: Must be exactly \" .. typeNames[1] .. \".\")\n else\n table.insert(eventOutcomeLines, \"- Type: Must be exactly one of \" .. table.concat(typeNames, \", \") .. \".\")\n end\n\n for _, typeInfo in ipairs(routeTypeList) do\n local option = typeOptions[typeInfo.id]\n\n if option then\n local optionTable = {}\n\n if type(option) == \"table\" then\n optionTable = option\n end\n\n local line = typeInfo.line\n\n if optionTable.suffix then\n line = line .. optionTable.suffix\n end\n\n line = line .. \".\"\n\n if optionTable.exactlyOnce == true then\n line = line .. \" This must appear exactly once.\"\n end\n\n table.insert(eventOutcomeLines, line)\n end\n end\n end\n\n if curPhase == constPhase.free then\n selectedEvent = getEventLabel(\"free\")\n\n eventButtonEnabled.resolveSituation = true\n eventButtonEnabled.dailySkip = true\n\n eventButtonEnabled.hookHeroine = true\n eventButtonEnabled.hookRandom = true\n\n eventButtonEnabled.createRoute = true\n eventButtonEnabled.createCustomRoute = true\n\n eventButtonEnabled.createHScene = true\n eventButtonEnabled.createCustomHScene = true\n\n eventButtonEnabled.event = true\n eventButtonEnabled.randomEvent = true\n\n eventButtonEnabled.masturbation = true\n eventButtonEnabled.sexualTrait = true\n\n elseif curPhase == constPhase.createRoute or curPhase == constPhase.createHScene then\n setChatVar(triggerId, \"hr_Route_Main\", selectedName)\n\n if curPhase == constPhase.createRoute then\n choiceFlag = constChoice.choice_CreateRoute\n\n local setupLine = \"- Setup: A short phrase describing only the opening premise and immediate situation of the route.\"\n local routeCustom = trim(safeGet(triggerId, \"hr_Route_Custom\", \"\"))\n local typeOptions = {}\n\n if routeCustom ~= \"\" then\n typeOptions = {\n [routeChoiceType.event] = true,\n }\n else\n typeOptions = {\n [routeChoiceType.event] = true,\n [routeChoiceType.incapacitated] = {\n suffix = \" and exposed to sexual exploitation or sexual violation\",\n exactlyOnce = true,\n },\n }\n end\n\n addRouteChoiceFieldLines(selectedName, setupLine, typeOptions)\n\n selectedEvent = getEventLabel(\"createRoute\") .. \" (\" .. selectedName .. \")\"\n\n elseif curPhase == constPhase.createHScene then\n choiceFlag = constChoice.choice_CreateHScene\n\n addRouteChoiceFieldLines(selectedName, \"- Setup: A short phrase describing only the opening premise and immediate vulnerable situation of the route.\", {\n [routeChoiceType.incapacitated] = true,\n [routeChoiceType.trapped] = true,\n [routeChoiceType.stranded] = true,\n [routeChoiceType.bargained] = true,\n })\n\n selectedEvent = getEventLabel(\"createHScene\") .. \" (\" .. selectedName .. \")\"\n end\n\n eventMessage = \"Continue \" .. selectedName .. \"'s story from the latest confirmed date and situation while leaving enough narrative space for multiple possible directions, so Route Choices can differ clearly in time, location, and setup.\"\n\n elseif curPhase == constPhase.addSexualTrait then\n selectedEvent = getEventLabel(\"sexualTrait\")\n\n elseif curPhase == constPhase.event then\n choiceFlag = constChoice.choice_EventDevelop\n local eventObjective = \"\"\n\n local turn = tonumber(getResolvedDualFlag(triggerId, hrSessionStatus.varName, hrSessionStatus.turn, 0)) or 0\n\n table.insert(eventOutcomeLines, \" Favorable: The situation turns in the heroine side's favor.\")\n table.insert(eventOutcomeLines, \" Setback: The situation worsens against the heroine side.\")\n table.insert(eventOutcomeLines, \" Molestation: One or more female members of the heroine side are subjected to sexual violation. This Outcome Type must be included at least once if the opposing side has sexual intent toward the heroine side.\")\n\n if turn > 0 then\n table.insert(eventOutcomeLines, \" Resolution: The heroine side favorably achieves or secures the Current Event Objective, ending the active situation in their favor.\")\n table.insert(eventOutcomeLines, \" Closure: The active situation fully ends for the heroine side in a mostly neutral way, regardless of whether the Current Event Objective is achieved, leaving them no longer involved in that same situation.\")\n table.insert(eventOutcomeLines, \" Incapacitated: \" .. mainHeroine .. \" is captured, restrained, forced to yield, or otherwise unable to resist or respond, leading her into a situation of sexual exploitation or sexual violation.\")\n table.insert(eventOutcomeLines, \" Resolution, Closure, and Incapacitated must completely and conclusively end the active situation, leave the heroine side no longer involved in it, and ensure it is not merely stalled, suspended, unresolved, or left as an ongoing impasse.\")\n if turn == 1 then\n table.insert(eventOutcomeLines, \" Resolution, Closure, and Incapacitated may be used only for Order 2 and are not mandatory.\")\n end\n end\n\n if turn > 1 then\n table.insert(eventOutcomeLines, \" Order 1 must use only Favorable, Setback, or Molestation.\")\n table.insert(eventOutcomeLines, \" Order 2 must use only Resolution, Closure, or Incapacitated.\")\n table.insert(eventOutcomeLines, \" Within each Order, the three allowed Outcome Types do not need to be balanced or evenly distributed; any single allowed Outcome Type may appear up to twice according to the actual situation, including control, leverage, relative advantage, and risk.\")\n else\n table.insert(eventOutcomeLines, \" Favorable, Setback, and Molestation do not bring the active situation to a full end.\")\n table.insert(eventOutcomeLines, \" Within each Order, the three Outcome Types do not need to be balanced or evenly distributed; any single Outcome Type may appear up to twice according to the actual situation, including control, leverage, relative advantage, and risk.\")\n end\n\n if applyEvent == true then\n sessionSaveFlag = constFlag.yes\n recordFlag = constRecordFlag.event_Start\n\n setChatVar(triggerId, \"hr_Event_Main\", selectedName)\n mainHeroine = selectedName\n\n eventMessage =\n \"Describe how and why \" .. mainHeroine .. \" becomes involved in an active event based on the current situation.\\n\" ..\n \"The event may take any form that naturally fits the current situation, as long as it creates an active situation that requires the heroine side to respond.\\n\" ..\n \"If such an event is already underway, keep it active during this response and develop it without immediately resolving it.\\n\" ..\n \"The event should carry meaningful risk: failure, misjudgment, disadvantage, or loss of control could leave \" .. mainHeroine .. \" exposed to sexual exploitation or sexual violation.\\n\" ..\n mainHeroine .. \" must remain able to respond to the active situation and must not become immediately incapacitated, trapped, or directly exposed to sexual exploitation or sexual violation in this response, unless she is already clearly and completely in such a state.\"\n\n selectedEvent = getEventLabel(\"event\") .. \" (\" .. mainHeroine .. \")\"\n else\n eventObjective = getResolvedDualFlag(triggerId, hrSessionStatus.varName, hrSessionStatus.objective, \"\")\n selectedEvent = \"🔄 이벤트 (\" .. mainHeroine .. \")\"\n end\n\n setChatVar(triggerId, \"hr_Event_Objective\", eventObjective)\n \n elseif curPhase == constPhase.event_Resolution then\n selectedEvent = \"🔄 이벤트 (\" .. mainHeroine .. \")\"\n\n elseif curPhase == constPhase.event_Incapacitated then\n choiceFlag = constChoice.choice_HScene_Trigger\n recordFlag = constRecordFlag.hScene_Incapacitation\n\n selectedEvent = \"🔄 이벤트 (\" .. mainHeroine .. \")\"\n\n elseif curPhase == constPhase.masturbation then\n choiceFlag = constChoice.choice_Masturbation\n\n if applyEvent == true then\n sessionSaveFlag = constFlag.yes\n setChatVar(triggerId, \"hr_Event_Main\", selectedName)\n mainHeroine = selectedName\n\n eventMessage =\n \"Describe naturally how and why \" .. mainHeroine .. \" begins to masturbate under the current circumstances, taking into account her personality, sexual knowledge, sexual values, lewdness, current situation, and any relevant context.\\n\" ..\n \"If possible, have her find a safe, secluded, and inconspicuous place; if not, she may have to manage it discreetly despite limited privacy. If the current situation makes privacy impossible, such as confinement, restricted movement, surveillance, or being unable to leave, she may be forced to do it secretly, under the risk of being discovered, or even while someone is watching if there is no other choice.\\n\" ..\n \"The masturbation scene must remain in its beginning stage; \" .. mainHeroine .. \" must not reach climax in this response.\\n\" ..\n \"There must be no immediate physical threat, attack, or realistic chance of being attacked.\"\n end\n\n do\n local vaginalFlag = constFlag.no\n local analFlag = constFlag.no\n\n local heroineCodeList = getHeroineCodeList(triggerId)\n local targetIndex = 0\n\n for i, code in ipairs(heroineCodeList) do\n local name = trim(getCharacterNameByCode(triggerId, code) or \"\")\n\n if name ~= \"\" and name == mainHeroine then\n targetIndex = i\n break\n end\n end\n\n if targetIndex > 0 then\n if getFirstExperienceFlagByPosition(triggerId, targetIndex, constFirstExperience.vaginal) == constFlag.yes then\n vaginalFlag = constFlag.yes\n end\n\n if getFirstExperienceFlagByPosition(triggerId, targetIndex, constFirstExperience.anal) == constFlag.yes then\n analFlag = constFlag.yes\n end\n end\n\n setChatVar(triggerId, \"hr_Masturbation_Vaginal\", vaginalFlag)\n setChatVar(triggerId, \"hr_Masturbation_Anal\", analFlag)\n end\n\n selectedEvent = getEventLabel(\"masturbation\") .. \" (\" .. mainHeroine .. \")\"\n elseif curPhase == constPhase.masturbation_End then\n arousalCheckFlag = constFlag.no\n selectedEvent = getEventLabel(\"masturbation\") .. \" (\" .. mainHeroine .. \")\"\n\n elseif curPhase == constPhase.hScene_Event then\n selectedEvent = \"🔞 H씬\"\n choiceFlag = constChoice.choice_HScene_Event\n\n if applyEvent == true then\n sessionSaveFlag = constFlag.yes\n recordFlag = constRecordFlag.hScene_Transition\n else\n recordFlag = constRecordFlag.hScene_Progress\n end\n\n violationTitle = getResolvedDualFlag(triggerId, hrSessionStatus.varName, hrSessionStatus.title, \"\")\n\n elseif curPhase == constPhase.hScene_End then\n selectedEvent = \"⏹️ 상황 종료\"\n choiceFlag = constChoice.choice_HScene_After\n summaryFlag = constFlag.yes\n sexualEffectFlag = constFlag.yes\n end\n\n setChatVar(triggerId, \"hr_Choice_Flag\", tostring(choiceFlag))\n setChatVar(triggerId, \"hr_Event_Outcome\", table.concat(eventOutcomeLines, \"\\n\"))\n setChatVar(triggerId, \"hr_SexualTrait_Flag\", getSexualTraitFlag())\n setChatVar(triggerId, \"hr_ArousalCheck_Flag\", arousalCheckFlag)\n setChatVar(triggerId, \"hr_Violation_Title\", violationTitle)\n setChatVar(triggerId, \"hr_Record_Flag\", tostring(recordFlag))\n setChatVar(triggerId, \"hr_Summary_Flag\", summaryFlag)\n setChatVar(triggerId, \"hr_SexualEffect_Flag\", sexualEffectFlag)\n\n setChatVar(triggerId, \"hr_Event_Start\", eventMessage)\n\n setChatVar(triggerId, \"hr_SessionSave_Flag\", sessionSaveFlag)\n\n local eventButtonParts = {}\n for _, buttonInfo in ipairs(eventButtonList) do\n if eventButtonEnabled[buttonInfo.key] == true then\n local attrName = buttonInfo.attrName or \"risu-trigger\"\n local attrValue = buttonInfo.trigger\n\n if attrName == \"risu-btn\" then\n attrValue = \"choice_\" .. attrValue\n end\n\n table.insert(eventButtonParts, '<button class=\"hr-event-button\" ' .. attrName .. '=\"' .. attrValue .. '\" title=\"' .. buttonInfo.title .. '\">' .. buttonInfo.icon .. '</button>')\n end\n end\n\n local eventButtonText = table.concat(eventButtonParts)\n if eventButtonText == \"\" then\n eventButtonText = '<span class=\"hr-event-empty\">진행중</span>'\n end\n\n setChatVar(triggerId, \"hr_Phase_Title\", selectedEvent)\n setChatVar(triggerId, \"hr_Phase_Body\", eventButtonText)\n\n if applyEvent == true and eventMessage ~= \"\" then\n local eventInputTitle = selectedEvent\n\n if forceSelectedName ~= \"\" then\n eventInputTitle = \"NULL\"\n end\n\n local eventInputText = \"[HR Event Start: \" .. eventInputTitle .. \"]\"\n local markerApplied = false\n\n local cl = getChatLength(triggerId)\n\n if cl > 0 then\n local lastIndex = cl - 1\n local last = getChat(triggerId, lastIndex)\n\n if last and last.role == \"user\" and type(last.data) == \"string\" then\n local lastText = tostring(last.data or \"\")\n\n -- 기존 HR Event Start 마커 제거 후 새 마커로 교체\n lastText = lastText:gsub(\"%s*%[HR Event Start:%s*.-%]%s*\", \"\\n\")\n lastText = lastText:gsub(\"\\r\\n\", \"\\n\"):gsub(\"\\r\", \"\\n\")\n lastText = lastText:gsub(\"\\n%s*\\n+\", \"\\n\")\n lastText = trim(lastText)\n\n if lastText ~= \"\" then\n lastText = lastText .. \"\\n\" .. eventInputText\n else\n lastText = eventInputText\n end\n\n setChat(triggerId, lastIndex, lastText)\n markerApplied = true\n end\n end\n\n if markerApplied == false then\n addChat(triggerId, \"user\", eventInputText)\n end\n end\n\n if applyEvent == true then\n searchMentionedCharacter(triggerId)\n end\nend\n\nfunction getPhase(triggerId)\n return tonumber(getResolvedDualFlag(triggerId, hrContext.varName, hrContext.phase, 0)) or 0\nend\n\nfunction getCurrentPhase(triggerId)\n return tonumber(getDualFlag(triggerId, hrContext.varName, hrContext.phase, false)) or 0\nend\n\n-- ==========================================\n-- 버튼\n-- ==========================================\nfunction selectCreateRoute(triggerId)\n setChatVar(triggerId, \"hr_Route_Custom\", \"\")\n setPhase(triggerId, constPhase.createRoute, true)\nend\n\nfunction selectCreateHScene(triggerId)\n setChatVar(triggerId, \"hr_Route_Custom\", \"\")\n setPhase(triggerId, constPhase.createHScene, true)\nend\n\nselectCreateCustomRouteCommon = async(function(triggerId, targetPhase)\n local selectedName = trim(safeGet(triggerId, \"hr_Selected_Name\", \"\"))\n\n if selectedName == \"\" then\n alertError(triggerId, \"❌ 선택된 캐릭터가 없습니다.\")\n return\n end\n\n local current = safeGet(triggerId, \"hr_Route_Custom\", \"\")\n\n local message = \"원하는 선택지 방향을 입력하세요.\"\n\n if current ~= \"\" then\n message = message .. \"\\n\\n현재 입력값: \" .. current\n end\n\n local input = alertInput(triggerId, message):await()\n\n if input == nil then\n return\n end\n\n input = trim(input)\n\n if input == \"\" then\n return\n end\n\n setChatVar(triggerId, \"hr_Route_Custom\", input)\n setPhase(triggerId, targetPhase, true)\nend)\n\nselectCreateCustomRoute = async(function(triggerId)\n selectCreateCustomRouteCommon(triggerId, constPhase.createRoute):await()\nend)\n\nselectCreateCustomHScene = async(function(triggerId)\n selectCreateCustomRouteCommon(triggerId, constPhase.createHScene):await()\nend)\n\nfunction selectEventStart(triggerId)\n setPhase(triggerId, constPhase.event, true)\nend\n\nfunction selectEventMasturbation(triggerId)\n setPhase(triggerId, constPhase.masturbation, true)\nend\n\nfunction selectAddSexualTrait(triggerId)\n setPhase(triggerId, constPhase.addSexualTrait, true)\nend\n\nfunction selectResolveSituation(triggerId)\n setPhase(triggerId, constPhase.free, true)\n\n local eventMessage =\n \"Describe the current or immediately pending situation until it ends, including its immediate aftermath.\\n\" ..\n \"If needed, bridge any passage of time briefly while preserving the key developments.\\n\" ..\n \"Let the specific course and outcome vary according to the situation and established context.\"\n\n setChatVar(triggerId, \"hr_Event_Start\", eventMessage)\n\n pushChoiceInput(triggerId, \"[HR Event Start: 🚩 상황 종결]\")\nend\n\nfunction selectDailySkip(triggerId)\n local selectedName = trim(safeGet(triggerId, \"hr_Selected_Name\", \"\"))\n\n if selectedName == \"\" then\n alertError(triggerId, \"❌ 선택된 캐릭터가 없습니다.\")\n return\n end\n\n setPhase(triggerId, constPhase.free, true)\n\n local eventMessage =\n \"Describe \" .. selectedName .. \"'s daily life after the latest confirmed date and time, continuing naturally from the current situation.\\n\" ..\n \"Advance time naturally until the scene reaches the next narratively meaningful moment.\\n\" ..\n \"Briefly summarize the intervening time in passing instead of depicting it in detail.\"\n\n setChatVar(triggerId, \"hr_Event_Start\", eventMessage)\n\n pushChoiceInput(triggerId, \"[HR Event Start: ⏳ 일상 스킵 (\" .. selectedName .. \")]\")\nend\n\nfunction selectHookCommon(triggerId, mode)\n mode = tostring(mode or \"\")\n\n local selectedName = \"\"\n local markerIcon = \"\"\n\n if mode == \"heroine\" then\n selectedName = trim(safeGet(triggerId, \"hr_Selected_Name\", \"\"))\n\n if selectedName == \"\" then\n alertError(triggerId, \"❌ 선택된 캐릭터가 없습니다.\")\n return\n end\n\n markerIcon = \"💡\"\n\n elseif mode == \"random\" then\n local heroineCodeList = getHeroineCodeList(triggerId)\n\n if #heroineCodeList <= 0 then\n alertError(triggerId, \"❌ 히로인 목록이 없습니다.\")\n return\n end\n\n local randomCode = heroineCodeList[math.random(#heroineCodeList)]\n selectedName = trim(getCharacterNameByCode(triggerId, randomCode) or \"\")\n\n if selectedName == \"\" then\n alertError(triggerId, \"❌ 랜덤 히로인 이름을 가져올 수 없습니다.\")\n return\n end\n\n markerIcon = \"🎲\"\n end\n\n local targetLine = \"Target: \" .. selectedName .. \", or her affiliations\\n\"\n local markerText = \"[HR Event Start: \" .. markerIcon .. \" 사건 발생 (\" .. selectedName .. \")]\"\n\n setChatVar(triggerId, \"hr_Route_Custom\", \"Different possible first steps toward a new narrative milestone\")\n setPhase(triggerId, constPhase.createRoute, true, selectedName)\n\n local eventMessage =\n targetLine ..\n \"Create a medium-term or long-term narrative milestone connected to the target and the established story context that can lead the story to move, change, or branch over time.\\n\" ..\n \"It may be tied to the target, the target's goals, role, surroundings, affiliations, or anything meaningfully connected to the target, as long as it can become a motive or catalyst for the target to move.\\n\" ..\n \"It should remain open-ended without being fully developed or resolved yet, including matters the target has not yet become involved in, taken part in, made a choice about, or decided whether to pursue alone or with someone else.\"\n\n setChatVar(triggerId, \"hr_Event_Start\", eventMessage)\n\n pushChoiceInput(triggerId, markerText)\n searchMentionedCharacter(triggerId)\nend\n\nfunction selectHookHeroine(triggerId)\n selectHookCommon(triggerId, \"heroine\")\nend\n\nfunction selectHookRandom(triggerId)\n selectHookCommon(triggerId, \"random\")\nend\n\n-- ==========================================\n-- 초기화\n-- ==========================================\nfunction resetSession(triggerId)\n setChatVar(triggerId, \"hr_Session_Status\", \"\")\n setChatVar(triggerId, \"hr_Event_Main\", \"\")\nend\n\n-- ==========================================\n-- 세이브\n-- ==========================================\nfunction loadData(triggerId)\n restoreFullSave(triggerId)\n \n setChatVar(triggerId, \"hr_Event_Start\", \"\")\n setChatVar(triggerId, \"hr_Event_Message\", \"\")\n setChatVar(triggerId, \"hr_Event_Action\", \"\")\n setChatVar(triggerId, \"hr_Event_End\", \"\")\n\n setChatVar(triggerId, \"hr_TrackValue\", \"\")\n setChatVar(triggerId, \"hr_Memory_Target\", \"\")\n \n processPregnancyStartChecks(triggerId, nil)\n\n local curPhase = getPhase(triggerId)\n setPhase(triggerId, curPhase)\n\n generateHTML(triggerId)\n\n alertNormal(triggerId, \"✅ 불러오기 완료\")\nend\n\nfunction buildFullSave(triggerId)\n\n local FS = \"\\x1F\" -- field separator\n local RS = \"\\x1E\" -- record separator\n local GS = \"\\x1D\" -- section separator\n local TS = \"\\x1C\" -- tag separator\n\n local SAVE_FULL_TAG = \"SaveFullData\"\n\n local sections = {}\n\n -- ==========================================\n -- 1. Global 변수 저장\n -- 나중 -> 예전 복원만 가능.\n -- ==========================================\n local globalKeys = {\n \"hr_Context\",\n \"hr_LastDate\",\n \"hr_LastPeriod\",\n\n \"hr_ConceptionRisk\",\n \"hr_Pregnancy\",\n\n \"hr_FirstExperiences\",\n\n \"hr_SexualTrait_Code\",\n \"hr_SexualEffect_Code\",\n\n \"hr_Memory_Num\",\n\n \"hr_Event_Main\",\n \"hr_Session_Status\",\n }\n\n local globalFields = {}\n\n for _, key in ipairs(globalKeys) do\n local val = tostring(getChatVar(triggerId, key) or \"\")\n table.insert(globalFields, key .. \"=\" .. val)\n end\n\n table.insert(sections, \"G\" .. TS .. table.concat(globalFields, FS))\n\n -- ==========================================\n -- 3. 최종 블록 생성\n -- ==========================================\n local saveBlock =\n \"<\" .. SAVE_FULL_TAG .. \">\"\n .. table.concat(sections, GS)\n .. \"</\" .. SAVE_FULL_TAG .. \">\"\n\n return saveBlock\nend\n\nfunction buildSessionSave(triggerId, extraChanged)\n\n local FS = \"\\x1F\"\n\n local saveMap = {}\n local saveOrder = {}\n\n local function putSaveValue(key, value)\n key = trim(tostring(key or \"\"))\n\n if key == \"\" then\n return\n end\n\n if saveMap[key] == nil then\n table.insert(saveOrder, key)\n end\n\n saveMap[key] = tostring(value or \"\")\n end\n\n -- ==========================================\n -- 1. extraChanged 먼저 넣음\n -- 나중에 track / force가 같은 키를 현재 ChatVar 값으로 덮어씀\n -- ==========================================\n if extraChanged then\n for key, value in pairs(extraChanged) do\n putSaveValue(key, value)\n end\n end\n\n -- ==========================================\n -- 2. TrackValue 키들은 현재 ChatVar 값을 읽어서 덮어씀\n -- ==========================================\n local trackRaw = safeGet(triggerId, \"hr_TrackValue\", \"\")\n\n if trackRaw ~= \"\" then\n local trackKeys = splitLiteral(trackRaw, \"|\")\n\n for _, key in ipairs(trackKeys) do\n key = trim(key or \"\")\n\n if key ~= \"\" then\n local val = tostring(getChatVar(triggerId, key) or \"\")\n putSaveValue(key, val)\n end\n end\n end\n\n -- ==========================================\n -- 3. Session 강제 저장 키도 현재 ChatVar 값을 읽어서 최종 덮어씀\n -- ==========================================\n local sessionForceKeys = {}\n\n if safeGet(triggerId, \"hr_SessionSave_Flag\", constFlag.no) == constFlag.yes then\n sessionForceKeys = {\n \"hr_Context\",\n \"hr_Event_Main\",\n \"hr_Session_Status\",\n }\n end\n\n for _, key in ipairs(sessionForceKeys) do\n key = trim(tostring(key or \"\"))\n\n if key ~= \"\" then\n local val = tostring(getChatVar(triggerId, key) or \"\")\n putSaveValue(key, val)\n end\n end\n\n if #saveOrder == 0 then\n return \"\"\n end\n\n local parts = {}\n\n for _, key in ipairs(saveOrder) do\n table.insert(parts, key .. \"=\" .. tostring(saveMap[key] or \"\"))\n end\n\n return \"<SaveSessionData>\"\n .. table.concat(parts, FS)\n .. \"</SaveSessionData>\"\nend\n\nfunction restoreFullSave(triggerId)\n\n local FS = \"\\x1F\"\n local RS = \"\\x1E\"\n local GS = \"\\x1D\"\n local TS = \"\\x1C\"\n\n local history = getFullChat(triggerId)\n if not history or #history == 0 then\n return false\n end\n\n local fullIndex = nil\n -- ======================================\n -- 1) 최신 FullSave 찾기 (아래 → 위)\n -- ======================================\n for i = #history, 1, -1 do\n if history[i].role == \"char\" then\n local text = history[i].data or \"\"\n if text:find(\"<SaveFullData>\", 1, true) then\n fullIndex = i\n break\n end\n end\n end\n\n if not fullIndex then\n return false\n end\n\n -- ======================================\n -- 2) FullSave 블록 추출\n -- ======================================\n local fullBlock =\n history[fullIndex].data:match(\n \"<SaveFullData>([%s%S]-)</SaveFullData>\"\n )\n\n if not fullBlock then\n return false\n end\n\n -- ======================================\n -- 3) FullSave 적용\n -- ======================================\n for section in fullBlock:gmatch(\"[^\" .. GS .. \"]+\") do\n\n local tag, body = section:match(\"^(.-)\" .. TS .. \"(.*)$\")\n\n if tag == \"G\" and body then\n -- Global\n for pair in body:gmatch(\"[^\" .. FS .. \"]+\") do\n local key, value = pair:match(\"^(.-)=(.*)$\")\n if key then\n setChatVar(triggerId, key, value or \"\")\n end\n end\n end\n end\n\n -- ======================================\n -- 4) Full 이후 SessionData 순차 적용\n -- ======================================\n for i = fullIndex + 1, #history do\n\n if history[i].role == \"char\" then\n\n local text = history[i].data or \"\"\n\n for block in text:gmatch(\n \"<SaveSessionData>([%s%S]-)</SaveSessionData>\"\n ) do\n\n for pair in block:gmatch(\"[^\" .. FS .. \"]+\") do\n local key, value = pair:match(\"^(.-)=(.*)$\")\n if key then\n setChatVar(triggerId, key, value or \"\")\n end\n end\n\n end\n end\n end\n\n -- ======================================\n -- First Experience Content 정합성 보정\n -- FullSave에는 hr_FirstExperiences 플래그만 저장하고 Content는 저장하지 않음\n -- 통합 플래그의 해당 자리가 1이 아닌 경우 Content 제거\n -- ======================================\n do\n local firstExperienceList = getFirstExperienceSlotList(triggerId)\n\n local function fixFirstExperienceContent(position, contentKey)\n local contentList = splitLiteral(safeGet(triggerId, contentKey, \"\"), \"|\")\n local out = {}\n\n for i = 1, math.max(#firstExperienceList, #contentList) do\n local flags = getFirstExperienceSlotFromList(firstExperienceList, i)\n\n if flags ~= \"\" and isFirstExperienceFlagYes(flags, position) then\n out[i] = trim(contentList[i] or \"\")\n else\n out[i] = \"\"\n end\n end\n\n setChatVar(triggerId, contentKey, table.concat(out, \"|\"))\n end\n\n fixFirstExperienceContent(constFirstExperience.kiss, \"hr_FirstKiss_Content\")\n fixFirstExperienceContent(constFirstExperience.oral, \"hr_FirstOral_Content\")\n fixFirstExperienceContent(constFirstExperience.vaginal, \"hr_FirstVaginal_Content\")\n fixFirstExperienceContent(constFirstExperience.anal, \"hr_FirstAnal_Content\")\n\n end\n\n -- ======================================\n -- Memory Content 정합성 보정\n -- FullSave에는 Memory_Num만 저장하고 Content는 저장하지 않음\n -- Num보다 많은 Content 기록은 제거\n -- ======================================\n do\n local function fixMemoryContent(numKey, contentKey)\n local memoryNum = tonumber(safeGet(triggerId, numKey, \"0\")) or 0\n local rawContent = safeGet(triggerId, contentKey, \"\")\n\n if memoryNum <= 0 then\n setChatVar(triggerId, contentKey, \"\")\n return\n end\n\n if rawContent == \"\" then\n return\n end\n\n local list = splitLiteral(rawContent, \"|\")\n\n if #list > memoryNum then\n local out = {}\n\n for i = 1, memoryNum do\n out[i] = list[i] or \"\"\n end\n\n setChatVar(triggerId, contentKey, table.concat(out, \"|\"))\n end\n end\n\n fixMemoryContent(\"hr_Memory_Num\", \"hr_Memory_Content\")\n end\n\n -- ======================================\n -- Sexual Trait Content 정합성 보정\n -- FullSave에는 hr_SexualTrait_Code만 저장하고 Content는 저장하지 않음\n -- Code에서 참조하는 가장 큰 번호까지만 Content를 유지함\n -- ======================================\n do\n local codeRaw = safeGet(triggerId, \"hr_SexualTrait_Code\", \"\")\n local maxTraitNum = 0\n\n for _, slotText in ipairs(splitLiteral(codeRaw, \"|\")) do\n for _, numRaw in ipairs(splitLiteral(slotText, \"^\")) do\n local num = tonumber(trim(numRaw or \"\")) or 0\n\n if num > maxTraitNum then\n maxTraitNum = num\n end\n end\n end\n\n if maxTraitNum <= 0 then\n setChatVar(triggerId, \"hr_SexualTrait_Content\", \"\")\n else\n local contentRaw = safeGet(triggerId, \"hr_SexualTrait_Content\", \"\")\n\n if contentRaw ~= \"\" then\n local contentList = splitLiteral(contentRaw, \"|\")\n\n if #contentList > maxTraitNum then\n local out = {}\n\n for i = 1, maxTraitNum do\n out[i] = contentList[i] or \"\"\n end\n\n setChatVar(triggerId, \"hr_SexualTrait_Content\", table.concat(out, \"|\"))\n end\n end\n end\n end\n\n return true\nend\n\n-- ==========================================\n-- 시작 설정\n-- ==========================================\nfunction initSetting(triggerId)\n if safeGet(triggerId, \"hr_Language\", \"\") == \"\" then\n setChatVar(triggerId, \"hr_Language\", \"1\")\n end\n\n setPhase(triggerId, constPhase.free)\n\n setChatVar(triggerId, \"hr_ConceptionRisk\", \"\")\n setChatVar(triggerId, \"hr_Pregnancy\", \"\")\n\n setChatVar(triggerId, \"hr_FirstExperiences\", \"\")\n\n setChatVar(triggerId, \"hr_FirstKiss_Content\", \"\")\n setChatVar(triggerId, \"hr_FirstOral_Content\", \"\")\n setChatVar(triggerId, \"hr_FirstVaginal_Content\", \"\")\n setChatVar(triggerId, \"hr_FirstAnal_Content\", \"\")\n\n setChatVar(triggerId, \"hr_ArousalResponse\", \"\")\n\n setChatVar(triggerId, \"hr_SexualTrait_Code\", \"\")\n setChatVar(triggerId, \"hr_SexualTrait_Content\", \"\")\n\n setChatVar(triggerId, \"hr_SexualEffect_Code\", \"\")\n setChatVar(triggerId, \"hr_SexualEffect_Content\", \"\")\n\n setChatVar(triggerId, \"hr_Memory_Num\", \"0\")\n setChatVar(triggerId, \"hr_Memory_Content\", \"\")\n\n setChatVar(triggerId, \"hr_Memory_Target\", \"\")\n setChatVar(triggerId, \"hr_Session_Status\", \"\")\n setChatVar(triggerId, \"hr_Scene_Main\", \"\")\n setChatVar(triggerId, \"hr_Event_Main\", \"\")\n\n local selectedCode = \"\"\n local heroineCodes = getHeroineCodeList(triggerId)\n if #heroineCodes > 0 then\n selectedCode = heroineCodes[1]\n end\n\n if selectedCode == \"\" then\n setChatVar(triggerId, \"hr_Selected_Code\", \"\")\n setChatVar(triggerId, \"hr_Selected_Name\", \"\")\n else\n local selectedName = getCharacterNameByCode(triggerId, selectedCode)\n\n if selectedName == \"\" then\n setChatVar(triggerId, \"hr_Selected_Code\", \"\")\n setChatVar(triggerId, \"hr_Selected_Name\", \"\")\n else\n setChatVar(triggerId, \"hr_Selected_Code\", selectedCode)\n setChatVar(triggerId, \"hr_Selected_Name\", selectedName)\n end\n end\n\n setChatVar(triggerId, \"hr_Option_Moan\", constFlag.no)\n\n setChatVar(triggerId, \"hr_StartSetting\", constFlag.yes)\n\n generateHTML(triggerId)\n\n setChatVar(triggerId, \"hr_Init\", constFlag.yes)\nend\n\nsetHeroineList = async(function(triggerId)\n local current = safeGet(triggerId, constHeroine.listKey, \"\")\n\n local message = \"히로인 목록을 입력하세요.\\n예) 박하연, 나카노 유이, 올리비아 밀러\"\n if current ~= \"\" then\n message = message .. \"\\n현재 입력값: \" .. current\n end\n\n local input = alertInput(triggerId, message):await()\n\n if input == nil or input == \"\" then\n return\n end\n\n applyHeroineCharList(triggerId, input)\n alertNormal(triggerId, \"✅ 히로인 목록 저장 완료\")\nend)\n\nfunction toggleStatusWindow(triggerId)\n local current = trim(safeGet(triggerId, \"hr_Option_StatusWindow\", constFlag.no))\n\n if current == constFlag.yes then\n setChatVar(triggerId, \"hr_Option_StatusWindow\", constFlag.no)\n else\n setChatVar(triggerId, \"hr_Option_StatusWindow\", constFlag.yes)\n end\nend\n\nfunction setLanguage1(triggerId)\n setChatVar(triggerId, \"hr_Language\", \"1\")\nend\n\nfunction setLanguage2(triggerId)\n setChatVar(triggerId, \"hr_Language\", \"2\")\nend\n\nfunction setLanguage3(triggerId)\n setChatVar(triggerId, \"hr_Language\", \"3\")\nend\n\n-- ==========================================\n-- 캐릭터 설정\n-- ==========================================\nfunction buildCharacterSearchText(name)\n local text = trim(tostring(name or \"\"))\n\n if text == \"\" then\n return \"\"\n end\n\n text = text:gsub(\"|\", \" \")\n\n -- 공백으로 떨어진 이름은 ^로 검색어 분리\n text = text:gsub(\"%s+\", \"^\")\n\n return text\nend\n\nfunction buildCharCodeString(charListText, prefix)\n local entries = splitLiteral(charListText or \"\", \",\")\n local normalizedNames = {}\n local codeParts = {}\n local searchParts = {}\n local codePrefix = prefix\n\n for _, entry in ipairs(entries) do\n entry = trim(entry)\n\n if entry ~= \"\" then\n local index = #normalizedNames + 1\n local codeKey = string.format(\"%s%03d\", codePrefix, index)\n local searchText = buildCharacterSearchText(entry)\n\n table.insert(normalizedNames, entry)\n table.insert(codeParts, codeKey .. \"=\" .. entry)\n table.insert(searchParts, searchText)\n end\n end\n\n return table.concat(normalizedNames, \", \"), table.concat(codeParts, \"|\"), table.concat(searchParts, \"|\")\nend\n\nfunction applyHeroineCharList(triggerId, charListText)\n local normalizedList, codeText, searchText = buildCharCodeString(\n charListText,\n constHeroine.codePrefix\n )\n\n if normalizedList == \"\" then\n return\n end\n\n setChatVar(triggerId, constHeroine.listKey, normalizedList)\n setChatVar(triggerId, constHeroine.codeKey, codeText)\n setChatVar(triggerId, constHeroine.searchKey, searchText)\nend\n\nfunction rebuildCharacterListKeyFromCodeKey(triggerId, genderConst)\n if not genderConst or not genderConst.listKey then\n return\n end\n\n local raw = safeGet(triggerId, genderConst.codeKey, \"\")\n local entries = splitLiteral(raw, \"|\")\n local names = {}\n\n for _, entry in ipairs(entries) do\n entry = trim(entry)\n\n if entry ~= \"\" then\n local code, name = entry:match(\"^%s*([^=]+)%s*=(.*)$\")\n\n code = trim(code or \"\")\n name = trim(name or \"\")\n\n if code ~= \"\" and name ~= \"\" then\n table.insert(names, name)\n end\n end\n end\n\n setChatVar(triggerId, genderConst.listKey, table.concat(names, \", \"))\nend\n\nfunction getCodeListFromVar(triggerId, key)\n local raw = safeGet(triggerId, key, \"\")\n local entries = splitLiteral(raw, \"|\")\n local codes = {}\n\n for _, entry in ipairs(entries) do\n entry = trim(entry)\n\n if entry ~= \"\" then\n local code = trim(entry:match(\"^([^=]+)\") or \"\")\n\n if code ~= \"\" then\n table.insert(codes, code)\n end\n end\n end\n\n return codes\nend\n\nfunction getHeroineCodeList(triggerId)\n return getCodeListFromVar(triggerId, constHeroine.codeKey)\nend\n\nfunction getCharCodeList(triggerId)\n local codes = {}\n\n for _, code in ipairs(getHeroineCodeList(triggerId)) do\n table.insert(codes, code)\n end\n\n return codes\nend\n\nfunction getCharacterSearchByCode(triggerId, code)\n code = trim(tostring(code or \"\"))\n\n if code == \"\" then\n return \"\"\n end\n\n local prefix = code:sub(1, 1)\n local index = tonumber(code:sub(2)) or 0\n\n if index < 1 then\n return \"\"\n end\n\n local searchKey = \"\"\n\n if prefix == constHeroine.codePrefix then\n searchKey = constHeroine.searchKey\n else\n return \"\"\n end\n\n local raw = safeGet(triggerId, searchKey, \"\")\n\n if raw ~= \"\" then\n local list = splitLiteral(raw, \"|\")\n return trim(list[index] or \"\")\n end\n\n return buildCharacterSearchText(getCharacterNameByCode(triggerId, code))\nend\n\nhr_search_selected = async(function(triggerId)\n local selectedCode = trim(safeGet(triggerId, \"hr_Selected_Code\", \"\"))\n local selectedName = trim(safeGet(triggerId, \"hr_Selected_Name\", \"\"))\n\n if selectedCode == \"\" or selectedName == \"\" then\n alertError(triggerId, \"❌ 선택된 캐릭터가 없습니다.\")\n return\n end\n\n local prefix = selectedCode:sub(1, 1)\n local index = tonumber(selectedCode:sub(2)) or 0\n\n if index < 1 then\n alertError(triggerId, \"❌ 선택된 캐릭터 코드가 올바르지 않습니다.\")\n return\n end\n\n local searchKey = \"\"\n\n if prefix == constHeroine.codePrefix then\n searchKey = constHeroine.searchKey\n else\n alertError(triggerId, \"❌ 선택된 캐릭터 코드가 올바르지 않습니다.\")\n return\n end\n\n local list = splitLiteral(safeGet(triggerId, searchKey, \"\"), \"|\")\n local currentSearch = trim(list[index] or \"\")\n\n if currentSearch == \"\" then\n currentSearch = selectedName:gsub(\"%s+\", \"^\")\n end\n\n local message =\n selectedName .. \"의 활성화 키를 , 로 구분해서 입력하세요.\\n\" ..\n \"현재 검색어: \" .. currentSearch:gsub(\"%^\", \", \")\n\n local input = alertInput(triggerId, message):await()\n\n if input == nil then\n return\n end\n\n input = trim(input)\n\n if input == \"\" then\n return\n end\n\n if input:find(\"^\", 1, true) or input:find(\"|\", 1, true) then\n alertError(triggerId, \"❌ 검색어에는 ^ 또는 | 문자를 사용할 수 없습니다.\")\n return\n end\n\n local searchText = \"\"\n\n for _, part in ipairs(splitLiteral(input, \",\")) do\n part = trim(part)\n\n if part ~= \"\" then\n searchText = appendSeparated(searchText, part, \"^\")\n end\n end\n\n if searchText == \"\" then\n return\n end\n\n for i = #list + 1, index do\n list[i] = \"\"\n end\n\n list[index] = searchText\n\n setChatVar(triggerId, searchKey, table.concat(list, \"|\"))\n\n alertNormal(triggerId, \"✅ 활성화 키 설정 완료\")\nend)\n\n-- ==========================================\n-- 공용 캐릭터\n-- ==========================================\nfunction getCharacterNameByCode(triggerId, code)\n code = trim(tostring(code or \"\"))\n\n local function findName(codeKey)\n local raw = safeGet(triggerId, codeKey, \"\")\n local entries = splitLiteral(raw, \"|\")\n\n for _, entry in ipairs(entries) do\n entry = trim(entry)\n\n if entry ~= \"\" then\n local c, name = entry:match(\"^%s*([^=]+)%s*=(.*)$\")\n\n c = trim(c or \"\")\n name = trim(name or \"\")\n\n if c == code then\n return name\n end\n end\n end\n\n return \"\"\n end\n\n local name = findName(constHeroine.codeKey)\n if name ~= \"\" then\n return name\n end\n\n return \"\", \"\"\nend\n\nfunction getHeroineIndexByName(triggerId, heroineName)\n heroineName = trim(tostring(heroineName or \"\"))\n\n if heroineName == \"\" then\n return 0\n end\n\n local heroineCodes = getHeroineCodeList(triggerId)\n\n for i, code in ipairs(heroineCodes) do\n local name = trim(tostring(getCharacterNameByCode(triggerId, code) or \"\"))\n\n if name == heroineName then\n return i\n end\n end\n\n return 0\nend\n\nfunction getCharacterCodeByName(triggerId, actorName)\n actorName = trim(tostring(actorName or \"\"))\n\n if actorName == \"\" then\n return \"\"\n end\n\n local function findCode(codeKey)\n local raw = safeGet(triggerId, codeKey, \"\")\n local entries = splitLiteral(raw, \"|\")\n\n for _, entry in ipairs(entries) do\n entry = trim(entry)\n\n if entry ~= \"\" then\n local code, name = entry:match(\"^%s*([^=]+)%s*=(.*)$\")\n\n code = trim(code or \"\")\n name = trim(name or \"\")\n\n if code ~= \"\" and name == actorName then\n return code\n end\n end\n end\n\n return \"\"\n end\n\n return findCode(constHeroine.codeKey)\nend\n-- ==========================================\n-- 공용 성적 특성\n-- ==========================================\nfunction getSexualTraitNameListByIndex(triggerId, targetIndex)\n targetIndex = tonumber(targetIndex) or 0\n\n local traitNames = {}\n\n if targetIndex < 1 then\n return traitNames\n end\n\n local traitCodeSlots = splitLiteral(safeGet(triggerId, \"hr_SexualTrait_Code\", \"\"), \"|\")\n local traitContentList = splitLiteral(safeGet(triggerId, \"hr_SexualTrait_Content\", \"\"), \"|\")\n local traitNums = splitLiteral(traitCodeSlots[targetIndex] or \"\", \"^\")\n\n local usedTraitMap = {}\n\n for _, numRaw in ipairs(traitNums) do\n local num = tonumber(trim(numRaw or \"\")) or 0\n local traitName = trim(traitContentList[num] or \"\")\n\n if num > 0 and traitName ~= \"\" and usedTraitMap[traitName] ~= true then\n table.insert(traitNames, traitName)\n usedTraitMap[traitName] = true\n end\n end\n\n return traitNames\nend\n\nfunction buildSexualTraitOwnedText(triggerId)\n local heroineNameRaw = trim(safeGet(triggerId, \"hr_List_Heroine\", \"\"))\n\n if heroineNameRaw == \"\" then\n return \"\"\n end\n\n local lines = {}\n local usedHeroineMap = {}\n\n heroineNameRaw = heroineNameRaw:gsub(\"\\r\\n\", \",\")\n heroineNameRaw = heroineNameRaw:gsub(\"\\r\", \",\")\n heroineNameRaw = heroineNameRaw:gsub(\"\\n\", \",\")\n\n for _, heroineName in ipairs(splitLiteral(heroineNameRaw, \",\")) do\n heroineName = trim(heroineName)\n\n if heroineName ~= \"\" and usedHeroineMap[heroineName] ~= true then\n usedHeroineMap[heroineName] = true\n\n local targetIndex = getHeroineIndexByName(triggerId, heroineName)\n\n if targetIndex > 0 then\n local traitNames = getSexualTraitNameListByIndex(triggerId, targetIndex)\n\n if #traitNames > 0 then\n local suffix = \"\"\n\n if #traitNames >= 5 then\n suffix = \" (Max reached; no new traits, upgrades only)\"\n end\n\n table.insert(lines, heroineName .. \": \" .. table.concat(traitNames, \", \") .. suffix)\n else\n table.insert(lines, heroineName .. \": None\")\n end\n end\n end\n end\n\n return table.concat(lines, \"\\n\")\nend\n\nfunction getSexualEffectListByIndex(triggerId, targetIndex)\n targetIndex = tonumber(targetIndex) or 0\n\n local effectList = {}\n\n if targetIndex < 1 then\n return effectList\n end\n\n local effectCodeSlots = splitLiteral(safeGet(triggerId, \"hr_SexualEffect_Code\", \"\"), \"|\")\n local effectContentList = splitLiteral(safeGet(triggerId, \"hr_SexualEffect_Content\", \"\"), \"|\")\n local effectNums = splitLiteral(effectCodeSlots[targetIndex] or \"\", \"^\")\n\n for _, numRaw in ipairs(effectNums) do\n local num = tonumber(trim(numRaw or \"\")) or 0\n local rawText = trim(effectContentList[num] or \"\")\n\n if num > 0 and rawText ~= \"\" then\n local parts = splitLiteral(rawText, \"^\")\n\n local effectName = trim(parts[1] or \"\")\n local effectType = trim(parts[2] or \"\")\n local effectDesc = trim(parts[3] or \"\")\n\n table.insert(effectList, \"(\" .. effectType .. \") \" .. effectName .. \": \" .. effectDesc)\n end\n end\n\n return effectList\nend\n\nfunction getSexualEffectNameListByIndex(triggerId, targetIndex)\n targetIndex = tonumber(targetIndex) or 0\n\n local effectNames = {}\n\n if targetIndex < 1 then\n return effectNames\n end\n\n local effectCodeSlots = splitLiteral(safeGet(triggerId, \"hr_SexualEffect_Code\", \"\"), \"|\")\n local effectContentList = splitLiteral(safeGet(triggerId, \"hr_SexualEffect_Content\", \"\"), \"|\")\n local effectNums = splitLiteral(effectCodeSlots[targetIndex] or \"\", \"^\")\n\n local usedEffectMap = {}\n\n for _, numRaw in ipairs(effectNums) do\n local num = tonumber(trim(numRaw or \"\")) or 0\n local rawText = trim(effectContentList[num] or \"\")\n\n if num > 0 and rawText ~= \"\" then\n local parts = splitLiteral(rawText, \"^\")\n local effectName = trim(parts[1] or \"\")\n\n if effectName ~= \"\" and usedEffectMap[effectName] ~= true then\n table.insert(effectNames, effectName)\n usedEffectMap[effectName] = true\n end\n end\n end\n\n return effectNames\nend\n\nfunction buildSexualEffectOwnedText(triggerId, heroineNameRaw)\n heroineNameRaw = trim(tostring(heroineNameRaw or \"\"))\n\n if heroineNameRaw == \"\" then\n return \"\"\n end\n\n local lines = {}\n local usedHeroineMap = {}\n\n heroineNameRaw = heroineNameRaw:gsub(\"\\r\\n\", \",\")\n heroineNameRaw = heroineNameRaw:gsub(\"\\r\", \",\")\n heroineNameRaw = heroineNameRaw:gsub(\"\\n\", \",\")\n\n for _, heroineName in ipairs(splitLiteral(heroineNameRaw, \",\")) do\n heroineName = trim(heroineName)\n\n if heroineName ~= \"\" and usedHeroineMap[heroineName] ~= true then\n usedHeroineMap[heroineName] = true\n\n local targetIndex = getHeroineIndexByName(triggerId, heroineName)\n\n if targetIndex > 0 then\n local effectNames = getSexualEffectNameListByIndex(triggerId, targetIndex)\n\n if #effectNames > 0 then\n table.insert(lines, heroineName .. \": \" .. table.concat(effectNames, \", \"))\n else\n table.insert(lines, heroineName .. \": None\")\n end\n end\n end\n end\n\n return table.concat(lines, \"\\n\")\nend\n\nfunction stripActiveSexualTraitParentheses(triggerId, outputText)\n outputText = tostring(outputText or \"\")\n\n if outputText == \"\" then\n return outputText\n end\n\n local activeHeroineIndexMap = {}\n\n -- ==========================================\n -- 1. 활성화 히로인 수집\n -- ==========================================\n local activeRawList = {\n safeGet(triggerId, \"hr_List_Heroine\", \"\"),\n getRecentMentionedCharacterList(triggerId),\n }\n\n for _, raw in ipairs(activeRawList) do\n raw = trim(tostring(raw or \"\"))\n\n if raw ~= \"\" then\n raw = raw:gsub(\"\\r\\n\", \",\")\n raw = raw:gsub(\"\\r\", \",\")\n raw = raw:gsub(\"\\n\", \",\")\n raw = raw:gsub(\"|\", \",\")\n\n for _, name in ipairs(splitLiteral(raw, \",\")) do\n name = trim(name)\n\n if name ~= \"\" then\n local targetIndex = getHeroineIndexByName(triggerId, name)\n\n if targetIndex > 0 then\n activeHeroineIndexMap[targetIndex] = true\n end\n end\n end\n end\n end\n\n -- ==========================================\n -- 2. 활성화 히로인의 성적특성명 수집\n -- ==========================================\n local removalNameMap = {}\n local removalNames = {}\n\n for targetIndex, _ in pairs(activeHeroineIndexMap) do\n local traitNames = getSexualTraitNameListByIndex(triggerId, targetIndex)\n\n for _, traitName in ipairs(traitNames or {}) do\n traitName = trim(tostring(traitName or \"\"))\n\n if traitName ~= \"\" and removalNameMap[traitName] ~= true then\n removalNameMap[traitName] = true\n table.insert(removalNames, traitName)\n end\n\n -- 저장된 특성명이 \"한글명 (English Name)\" 형태일 경우\n -- 괄호 안쪽 이름도 제거 대상으로 추가\n for bracketText in traitName:gmatch(\"%b()\") do\n local inner = trim(bracketText:sub(2, -2))\n\n if inner ~= \"\" and removalNameMap[inner] ~= true then\n removalNameMap[inner] = true\n table.insert(removalNames, inner)\n end\n end\n\n -- 전각 괄호 대응\n for inner in traitName:gmatch(\"((.-))\") do\n inner = trim(inner)\n\n if inner ~= \"\" and removalNameMap[inner] ~= true then\n removalNameMap[inner] = true\n table.insert(removalNames, inner)\n end\n end\n end\n end\n\n -- ==========================================\n -- 2-1. 활성화 히로인의 Imposed Sexual Effects 이름 수집\n -- ==========================================\n for targetIndex, _ in pairs(activeHeroineIndexMap) do\n local effectNames = getSexualEffectNameListByIndex(triggerId, targetIndex)\n\n for _, effectName in ipairs(effectNames or {}) do\n effectName = trim(tostring(effectName or \"\"))\n\n if effectName ~= \"\" and removalNameMap[effectName] ~= true then\n removalNameMap[effectName] = true\n table.insert(removalNames, effectName)\n end\n\n -- EffectName 자체가 \"한글명 (English Name)\" 형태일 경우\n -- 괄호 안쪽 이름도 제거 대상으로 추가\n for bracketText in effectName:gmatch(\"%b()\") do\n local inner = trim(bracketText:sub(2, -2))\n\n if inner ~= \"\" and removalNameMap[inner] ~= true then\n removalNameMap[inner] = true\n table.insert(removalNames, inner)\n end\n end\n\n -- 전각 괄호 대응\n for inner in effectName:gmatch(\"((.-))\") do\n inner = trim(inner)\n\n if inner ~= \"\" and removalNameMap[inner] ~= true then\n removalNameMap[inner] = true\n table.insert(removalNames, inner)\n end\n end\n end\n end\n\n if #removalNames <= 0 then\n return outputText\n end\n\n -- 긴 이름 먼저 제거해야 겹치는 이름이 있어도 안전함\n table.sort(removalNames, function(a, b)\n return #a > #b\n end)\n\n -- ==========================================\n -- 3. 괄호 표기 제거\n -- ==========================================\n for _, removalName in ipairs(removalNames) do\n removalName = trim(tostring(removalName or \"\"))\n\n if removalName ~= \"\" then\n local patternParts = {}\n\n for part in removalName:gmatch(\"%S+\") do\n part = part:gsub(\"([%^%$%(%)%%%.%[%]%*%+%-%?])\", \"%%%1\")\n table.insert(patternParts, part)\n end\n\n local namePattern = table.concat(patternParts, \"%s+\")\n\n if namePattern ~= \"\" then\n outputText = outputText:gsub(\"%s*%(%s*\" .. namePattern .. \"%s*%)\", \"\")\n outputText = outputText:gsub(\"%s*(%s*\" .. namePattern .. \"%s*)\", \"\")\n end\n end\n end\n\n return outputText\nend\n\nfunction getLatestEventRecordSummary(triggerId, targetRecordType)\n targetRecordType = trim(tostring(targetRecordType or \"\"))\n\n if targetRecordType == \"\" then\n return \"\"\n end\n\n local history = getFullChat(triggerId)\n\n if not history or #history <= 0 then\n return \"\"\n end\n\n for i = #history, 1, -1 do\n local item = history[i]\n\n if item and item.role == \"char\" and type(item.data) == \"string\" then\n local text = tostring(item.data or \"\")\n\n if maskThoughtBlocksForParsing then\n text = maskThoughtBlocksForParsing(text)\n end\n\n local records = {}\n\n for body in text:gmatch(\"<Event Record[^>]*>%s*([%s%S]-)%s*</Event Record>\") do\n table.insert(records, body)\n end\n\n for r = #records, 1, -1 do\n local raw = trim(records[r] or \"\")\n\n raw = raw:match(\"%[(%s*Record Type:%s*[%s%S]-%s*)%]\") or raw\n raw = trim(raw)\n\n local recordType = trim(raw:match(\"Record%s+Type:%s*([%s%S]-)%s*|%s*Summary%s*:\") or \"\")\n local startDateTime = trim(raw:match(\"|%s*Start%s+DateTime:%s*([%s%S]-)%s*$\") or \"\")\n\n if recordType == targetRecordType and startDateTime ~= \"\" then\n return startDateTime\n end\n end\n end\n end\n\n return \"\"\nend\n\n-- ==========================================\n-- 캐릭터 목록\n-- ==========================================\nfunction generateHTML(triggerId)\n\n local function escapeHTML(s)\n s = tostring(s or \"\")\n\n s = s:gsub(\"&\", \"&\")\n s = s:gsub(\"<\", \"<\")\n s = s:gsub(\">\", \">\")\n s = s:gsub('\"', \""\")\n s = s:gsub(\"'\", \"'\")\n\n return s\n end\n\n local function buildProfileDisplayHTML(text)\n text = tostring(text or \"\")\n text = text:gsub(\"\\r\\n\", \"\\n\"):gsub(\"\\r\", \"\\n\")\n\n local htmlParts = {}\n\n table.insert(htmlParts, '<div class=\"hr-info-body\">')\n\n for line in (text .. \"\\n\"):gmatch(\"(.-)\\n\") do\n if line == \"\" then\n table.insert(htmlParts, '<div class=\"hr-profile-gap\"></div>')\n else\n local headingText = line:match(\"^#+%s*(.*)$\")\n\n if headingText ~= nil then\n table.insert(htmlParts, '<div class=\"hr-profile-heading\">' .. escapeHTML(headingText) .. '</div>')\n else\n table.insert(htmlParts, '<div class=\"hr-profile-item\">' .. escapeHTML(line) .. '</div>')\n end\n end\n end\n\n table.insert(htmlParts, '</div>')\n\n return table.concat(htmlParts, \"\")\n end\n\n local function getCodeNameListFromVar(key)\n local raw = safeGet(triggerId, key, \"\")\n local entries = splitLiteral(raw, \"|\")\n local list = {}\n\n for _, entry in ipairs(entries) do\n entry = trim(entry)\n\n if entry ~= \"\" then\n local code, name = entry:match(\"^%s*([^=]+)%s*=(.*)$\")\n\n code = trim(code or \"\")\n name = trim(name or \"\")\n\n if code ~= \"\" and name ~= \"\" then\n table.insert(list, { code = code, name = name })\n end\n end\n end\n\n return list\n end\n\n local function buildCharacterDisplayHTML(genderConst)\n local list = getCodeNameListFromVar(genderConst.codeKey)\n local parts = {}\n\n for _, item in ipairs(list) do\n table.insert(\n parts,\n '<button class=\"hr-select-item\" risu-trigger=\"hr_select_' .. escapeHTML(item.code) .. '\">' ..\n escapeHTML(item.name) ..\n '</button>'\n )\n end\n\n return table.concat(parts, \"\")\n end\n\n local function buildFirstExperienceButtonPanelHTML(targetIndex)\n targetIndex = tonumber(targetIndex) or 0\n\n if targetIndex < 1 then\n return \"\"\n end\n\n local defs = {\n {\n key = \"kiss\",\n label = \"키스\",\n position = constFirstExperience.kiss,\n },\n {\n key = \"oral\",\n label = \"펠라치오\",\n position = constFirstExperience.oral,\n },\n {\n key = \"vaginal\",\n label = \"질내섹스\",\n position = constFirstExperience.vaginal,\n },\n {\n key = \"anal\",\n label = \"항문섹스\",\n position = constFirstExperience.anal,\n },\n }\n\n local buttonParts = {}\n\n for _, def in ipairs(defs) do\n local flagValue = getFirstExperienceFlagByPosition(triggerId, targetIndex, def.position)\n local triggerName = \"hr_first_selected_\" .. def.key\n\n if flagValue ~= constFlag.yes then\n table.insert(\n buttonParts,\n '<button class=\"hr-first-button\" risu-trigger=\"' .. escapeHTML(triggerName) .. '\">' ..\n escapeHTML(def.label) ..\n '</button>'\n )\n end\n end\n\n if #buttonParts <= 0 then\n return \"\"\n end\n\n return\n '<div class=\"hr-first-panel\">' ..\n '<div class=\"hr-first-row\">' ..\n '<div class=\"hr-first-label\">첫경험 설정</div>' ..\n '<div class=\"hr-first-buttons\">' ..\n table.concat(buttonParts, \"\") ..\n '</div>' ..\n '</div>' ..\n '</div>'\n end\n\n -- ==========================================\n -- 활성화 캐릭터\n -- ==========================================\n searchMentionedCharacter(triggerId)\n\n -- ==========================================\n -- 캐릭터 목록 표시\n -- ==========================================\n setChatVar(triggerId, constHeroine.displayKey, buildCharacterDisplayHTML(constHeroine))\n\n -- ==========================================\n -- 선택된 캐릭터 정보\n -- ==========================================\n do\n local selectedCode = trim(safeGet(triggerId, \"hr_Selected_Code\", \"\"))\n local selectedName = trim(safeGet(triggerId, \"hr_Selected_Name\", \"\"))\n\n if selectedCode == \"\" or selectedName == \"\" then\n setChatVar(triggerId, \"hr_Selected_Info_Display\", '<div class=\"hr-info-empty\">선택된 캐릭터가 없습니다.</div>')\n else\n local htmlParts = {}\n\n -- ==========================================\n -- 선택된 캐릭터 정보\n -- ==========================================\n\n table.insert(\n htmlParts,\n '<div class=\"hr-first-panel\">' ..\n '<div class=\"hr-first-row\">' ..\n '<div class=\"hr-first-label\">설정</div>' ..\n '<div class=\"hr-first-buttons\">' ..\n '<button class=\"hr-first-button\" risu-trigger=\"hr_search_selected\">활성화 키</button>' ..\n '</div>' ..\n '</div>' ..\n '</div>'\n )\n\n local selectedIsHeroine = selectedCode:sub(1, 1) == constHeroine.codePrefix\n local heroineIndex = 0\n\n if selectedIsHeroine then\n local heroineList = getCodeNameListFromVar(constHeroine.codeKey)\n\n for i, item in ipairs(heroineList) do\n if item.code == selectedCode then\n heroineIndex = i\n break\n end\n end\n\n table.insert(htmlParts, buildFirstExperienceButtonPanelHTML(heroineIndex))\n end\n\n if selectedIsHeroine then\n local sexualProfileText = buildSexualProfile(triggerId, heroineIndex)\n if sexualProfileText ~= \"\" then\n table.insert(htmlParts, buildProfileDisplayHTML(sexualProfileText))\n end\n end\n\n local memoryProfileText = buildMemoryProfile(triggerId, selectedCode, \"\")\n if memoryProfileText ~= \"\" then\n table.insert(htmlParts, buildProfileDisplayHTML(memoryProfileText))\n end\n\n setChatVar(triggerId, \"hr_Selected_Info_Display\", table.concat(htmlParts, \"\"))\n end\n end\nend\n\nfunction selectCharacterFromList(triggerId, code)\n code = trim(tostring(code or \"\"))\n\n if code == \"\" then\n return\n end\n\n local name = getCharacterNameByCode(triggerId, code)\n\n if name == \"\" then\n return\n end\n\n setChatVar(triggerId, \"hr_Selected_Code\", code)\n setChatVar(triggerId, \"hr_Selected_Name\", name)\n\n generateHTML(triggerId)\nend\n\nfor i = 1, 100 do\n local code = string.format(\"%s%03d\", constHeroine.codePrefix, i)\n\n _G[\"hr_select_\" .. code] = async(function(triggerId)\n selectCharacterFromList(triggerId, code)\n end)\nend\n\nfunction addGenderCharacter(triggerId, genderConst, labelText)\n local input = alertInput(triggerId, labelText .. \" 캐릭터 이름을 입력하세요.\\n캐릭터를 추가한 후에는 삭제할 수 없습니다.\"):await()\n\n if input == nil then\n return\n end\n\n local name = trim(input)\n\n -- 빈칸이면 취소\n if name == \"\" then\n return\n end\n\n -- Code/Search 구분자 깨짐 방지\n if name:find(\"|\", 1, true) or name:find(\",\", 1, true) or name:find(\"^\", 1, true) then\n alertError(triggerId, \"❌ 캐릭터 이름에는 |, ,, ^ 문자를 사용할 수 없습니다.\")\n return\n end\n\n -- 중복 이름 방지\n if getCharacterCodeByName(triggerId, name) ~= \"\" then\n alertError(triggerId, \"❌ 이미 존재하는 캐릭터입니다.\")\n return\n end\n\n -- 다음 코드 번호 계산\n local raw = safeGet(triggerId, genderConst.codeKey, \"\")\n local entries = splitLiteral(raw, \"|\")\n local maxIndex = 0\n\n for _, entry in ipairs(entries) do\n entry = trim(entry)\n\n if entry ~= \"\" then\n local code = trim(entry:match(\"^%s*([^=]+)\") or \"\")\n local index = tonumber(code:match(\"^\" .. genderConst.codePrefix .. \"(%d+)$\")) or 0\n\n if index > maxIndex then\n maxIndex = index\n end\n end\n end\n\n local index = maxIndex + 1\n\n -- html 선택 버튼이 100번까지만 있으니까 제한\n if index > 100 then\n alertError(triggerId, \"❌ 캐릭터는 현재 100명까지만 추가할 수 있습니다.\")\n return\n end\n\n local code = string.format(\"%s%03d\", genderConst.codePrefix, index)\n\n -- Code 뒤에 추가\n local codeRaw = safeGet(triggerId, genderConst.codeKey, \"\")\n codeRaw = appendSeparated(codeRaw, code .. \"=\" .. name, \"|\")\n setChatVar(triggerId, genderConst.codeKey, codeRaw)\n\n -- listKey도 Code 기준으로 다시 생성\n rebuildCharacterListKeyFromCodeKey(triggerId, genderConst)\n\n -- Search도 같은 번호 위치에 추가\n local searchList = splitLiteral(safeGet(triggerId, genderConst.searchKey, \"\"), \"|\")\n\n for i = #searchList + 1, index - 1 do\n searchList[i] = \"\"\n end\n\n searchList[index] = buildCharacterSearchText(name)\n setChatVar(triggerId, genderConst.searchKey, table.concat(searchList, \"|\"))\n\n generateHTML(triggerId)\n alertNormal(triggerId, \"✅ 캐릭터 추가 완료: \" .. name)\nend\n\naddHeroineCharacter = async(function(triggerId)\n addGenderCharacter(triggerId, constHeroine, \"히로인\")\nend)\n\nfunction toggleMoanDirective(triggerId)\n local current = trim(safeGet(triggerId, \"hr_Option_Moan\", constFlag.no))\n\n if current == constFlag.yes then\n setChatVar(triggerId, \"hr_Option_Moan\", constFlag.no)\n else\n setChatVar(triggerId, \"hr_Option_Moan\", constFlag.yes)\n end\nend\n\nfunction toggleDisplayResult(triggerId)\n local current = trim(safeGet(triggerId, \"hr_Option_Result\", constFlag.no))\n\n if current == constFlag.yes then\n setChatVar(triggerId, \"hr_Option_Result\", constFlag.no)\n else\n setChatVar(triggerId, \"hr_Option_Result\", constFlag.yes)\n end\nend\n\nfunction toggleCheat(triggerId)\n local current = trim(safeGet(triggerId, \"hr_Option_Cheat\", constFlag.no))\n\n if current == constFlag.yes then\n setChatVar(triggerId, \"hr_Option_Cheat\", constFlag.no)\n else\n setChatVar(triggerId, \"hr_Option_Cheat\", constFlag.yes)\n end\nend\n\n-- ==========================================\n-- 캐릭터 정보\n-- ==========================================\nfunction buildSexualProfile(triggerId, targetIndex)\n targetIndex = tonumber(targetIndex) or 0\n\n if targetIndex < 1 then\n return \"\"\n end\n\n local defs = {\n {\n label = \"First Kiss\",\n position = constFirstExperience.kiss,\n contentKey = \"hr_FirstKiss_Content\",\n },\n {\n label = \"First Oral Sex\",\n position = constFirstExperience.oral,\n contentKey = \"hr_FirstOral_Content\",\n },\n {\n label = \"First Vaginal Sex\",\n position = constFirstExperience.vaginal,\n contentKey = \"hr_FirstVaginal_Content\",\n },\n {\n label = \"First Anal Sex\",\n position = constFirstExperience.anal,\n contentKey = \"hr_FirstAnal_Content\",\n },\n }\n\n local cycleInfo = {\n [constCycle.menstruation] = {\n title = \"Menstruation\",\n text = \"She is currently menstruating and may experience mild cramps, fatigue, or lower abdominal discomfort. Pregnancy risk is nonexistent.\",\n },\n [constCycle.fertile_m5] = {\n title = \"Fertile Window (5 days before ovulation)\",\n text = \"Ovulation is expected in 5 days. There is an increasing probability of pregnancy.\",\n },\n [constCycle.fertile_m4] = {\n title = \"Fertile Window (4 days before ovulation)\",\n text = \"Ovulation is expected in 4 days. There is an increasing probability of pregnancy.\",\n },\n [constCycle.fertile_m3] = {\n title = \"Fertile Window (3 days before ovulation)\",\n text = \"Ovulation is expected in 3 days. There is an increasing probability of pregnancy.\",\n },\n [constCycle.fertile_m2] = {\n title = \"Fertile Window (2 days before ovulation)\",\n text = \"Ovulation is approaching. The probability of pregnancy is moderately high.\",\n },\n [constCycle.fertile_m1] = {\n title = \"Fertile Window (1 day before ovulation)\",\n text = \"Ovulation is imminent. The probability of pregnancy is very high.\",\n },\n [constCycle.ovulation] = {\n title = \"Fertile Window (Ovulation Day)\",\n text = \"Today is the ovulation day. The probability of pregnancy is at its highest.\",\n },\n [constCycle.fertile_p1] = {\n title = \"Fertile Window (1 day after ovulation)\",\n text = \"Ovulation has recently occurred. The probability of pregnancy remains elevated.\",\n },\n [constCycle.safe] = {\n title = \"Infertile Phase\",\n text = \"This is a safe period. There is no risk of pregnancy.\",\n },\n [constCycle.post_fertile] = {\n title = \"Infertile Phase\",\n text = \"This is a safe period. There is no risk of pregnancy.\",\n },\n [constCycle.pregnant] = {\n title = \"\",\n text = \"\",\n },\n [constCycle.pending] = {\n title = \"Cycle Status Uncertain\",\n text = \"\",\n },\n }\n\n local function getPregnancyEquivalentTextByProgress(progress, daysTotal)\n progress = clamp(tonumber(progress) or 0, 0, 1)\n daysTotal = tonumber(daysTotal) or 0\n\n local humanPregnancyDays = 280\n local humanPregnancyToleranceRate = 0.10\n\n local minHumanPregnancyDays = humanPregnancyDays * (1 - humanPregnancyToleranceRate)\n local maxHumanPregnancyDays = humanPregnancyDays * (1 + humanPregnancyToleranceRate)\n\n if daysTotal >= minHumanPregnancyDays and daysTotal <= maxHumanPregnancyDays then\n if progress < 0.10 then\n return \"earliest stage\"\n elseif progress < 0.30 then\n return \"first trimester\"\n elseif progress < 0.50 then\n return \"early second trimester\"\n elseif progress < 0.70 then\n return \"second trimester\"\n elseif progress < 0.85 then\n return \"early third trimester\"\n elseif progress < 0.95 then\n return \"late third trimester\"\n elseif progress < 1.00 then\n return \"full-term stage\"\n else\n return \"imminent-delivery stage\"\n end\n end\n\n if progress < 0.10 then\n return \"comparable to the earliest stage of human pregnancy\"\n elseif progress < 0.30 then\n return \"comparable to the early stage of human pregnancy\"\n elseif progress < 0.50 then\n return \"comparable to the early-to-middle stage of human pregnancy\"\n elseif progress < 0.70 then\n return \"comparable to the middle stage of human pregnancy\"\n elseif progress < 0.85 then\n return \"comparable to the middle-to-late stage of human pregnancy\"\n elseif progress < 0.95 then\n return \"comparable to the late stage of human pregnancy\"\n elseif progress < 1.00 then\n return \"comparable to a full-term stage of human pregnancy\"\n else\n return \"comparable to the imminent-delivery stage of human pregnancy\"\n end\n end\n\n local function getPregnancyPhysicalChangesLineByProgress(progress, daysTotal)\n progress = clamp(tonumber(progress) or 0, 0, 1)\n daysTotal = tonumber(daysTotal) or 0\n\n local equivalentText = getPregnancyEquivalentTextByProgress(progress, daysTotal)\n\n if progress < 0.10 then\n return \"No visible physical changes are apparent yet; outward signs of pregnancy are minimal or absent. (\" .. equivalentText .. \")\"\n elseif progress < 0.30 then\n return \"Early pregnancy symptoms may be present, but there is no visible abdominal change. (\" .. equivalentText .. \")\"\n elseif progress < 0.50 then\n return \"The abdomen shows gentle rounding consistent with early pregnancy. (\" .. equivalentText .. \")\"\n elseif progress < 0.70 then\n return \"The abdomen is noticeably enlarged, clearly indicating pregnancy. (\" .. equivalentText .. \")\"\n elseif progress < 0.85 then\n return \"The abdomen is prominently enlarged, reflecting late-stage pregnancy. (\" .. equivalentText .. \")\"\n elseif progress < 1.00 then\n return \"The abdomen is fully distended, indicating a near-term or full-term pregnancy. (\" .. equivalentText .. \")\"\n else\n return \"The abdomen is fully distended, and the pregnancy appears to be at the point where delivery may begin at any time. (\" .. equivalentText .. \")\"\n end\n end\n\n local function buildGestationalAgeText(elapsedDays)\n elapsedDays = math.max(0, tonumber(elapsedDays) or 0)\n\n if elapsedDays < 7 then\n local dayWord = (elapsedDays == 1) and \"day\" or \"days\"\n return tostring(elapsedDays) .. \" \" .. dayWord\n end\n\n local weeks = math.floor(elapsedDays / 7)\n local weekWord = (weeks == 1) and \"week\" or \"weeks\"\n\n return formatFloat(weeks) .. \" \" .. weekWord\n end\n\n local function buildApproximateDateText(dateTable)\n if not dateTable then\n return \"Not set.\"\n end\n\n local y = tonumber(dateTable.year)\n local m = tonumber(dateTable.month)\n local d = tonumber(dateTable.day)\n\n if not y or not m or not d then\n return \"Not set.\"\n end\n\n local monthNames = {\n \"January\", \"February\", \"March\", \"April\", \"May\", \"June\",\n \"July\", \"August\", \"September\", \"October\", \"November\", \"December\"\n }\n\n local phaseText = \"early\"\n\n if d >= 21 then\n phaseText = \"late\"\n elseif d >= 11 then\n phaseText = \"mid\"\n end\n\n return \"Year \" .. tostring(y) .. \", \" .. phaseText .. \" \" .. (monthNames[m] or \"Unknown\")\n end\n\n local function buildPregnancyStatusLines(targetKey, currentDateTable)\n local pregnancyLines = {}\n\n local pregnancyDateRaw = \"\"\n local fatherRaw = \"\"\n local daysTotalRaw = \"\"\n\n local pregnancyRecord = getCharacterPregnancyRecordByCode(triggerId, targetKey)\n local pregnancyParts = splitLiteral(pregnancyRecord, \"^\")\n\n pregnancyDateRaw = trim(pregnancyParts[1] or \"\")\n fatherRaw = trim(pregnancyParts[2] or \"\")\n daysTotalRaw = trim(pregnancyParts[3] or \"\")\n\n local pregnancyDateTable = parseDateText(pregnancyDateRaw)\n\n table.insert(pregnancyLines, \"- Pregnancy Status: Pregnant\")\n\n if pregnancyDateRaw ~= \"\" then\n table.insert(pregnancyLines, \"- Pregnancy Date: \" .. formatDateTextWithHyphen(pregnancyDateRaw))\n end\n\n if fatherRaw ~= \"\" then\n table.insert(pregnancyLines, \"- Father: \" .. fatherRaw)\n end\n\n local daysTotal = math.max(0, tonumber(daysTotalRaw) or 0)\n local elapsedDays = nil\n\n if pregnancyDateTable and currentDateTable then\n elapsedDays = diffDaysBetweenDateTables(pregnancyDateTable, currentDateTable)\n\n if elapsedDays == nil or elapsedDays < 0 then\n elapsedDays = 0\n end\n end\n\n if elapsedDays ~= nil then\n table.insert(pregnancyLines, \"- Gestational Age: \" .. buildGestationalAgeText(elapsedDays))\n end\n\n if pregnancyDateTable and daysTotal > 0 then\n local expectedDeliveryDateTable = addDaysToDate(pregnancyDateTable, daysTotal)\n\n if expectedDeliveryDateTable then\n table.insert(pregnancyLines, \"- Expected Delivery: \" .. buildApproximateDateText(expectedDeliveryDateTable))\n\n if currentDateTable then\n local remainingDays = diffDaysBetweenDateTables(currentDateTable, expectedDeliveryDateTable)\n\n if remainingDays ~= nil then\n local remainingText = \"\"\n\n remainingDays = math.floor(tonumber(remainingDays) or 0)\n\n if remainingDays <= 0 then\n remainingText = \"delivery may begin at any time\"\n elseif remainingDays <= 1 then\n remainingText = \"delivery appears imminent\"\n elseif remainingDays <= 3 then\n remainingText = \"only a few days remain\"\n elseif remainingDays < 7 then\n remainingText = \"less than a week remains\"\n else\n local weeks = math.max(1, roundHalfUp(remainingDays / 7))\n local weekWord = (weeks == 1) and \"week\" or \"weeks\"\n\n remainingText = \"about \" .. tostring(weeks) .. \" \" .. weekWord .. \" remain\"\n end\n\n table.insert(pregnancyLines, \"- Time Until Delivery: \" .. remainingText)\n end\n end\n end\n end\n\n if elapsedDays ~= nil and daysTotal > 0 then\n local progress = clamp(elapsedDays / daysTotal, 0, 1)\n\n table.insert(pregnancyLines, \"- Physical Changes: \" .. getPregnancyPhysicalChangesLineByProgress(progress, daysTotal))\n end\n\n return pregnancyLines\n end\n\n local lines = {}\n\n do\n local traitNames = getSexualTraitNameListByIndex(triggerId, targetIndex)\n\n if #traitNames > 0 then\n if #lines > 0 then\n table.insert(lines, \"\")\n end\n\n table.insert(lines, \"#### Sexual Traits\")\n for _, traitName in ipairs(traitNames) do\n table.insert(lines, \"- \" .. traitName)\n end\n end\n end\n\n do\n local sexualEffects = getSexualEffectListByIndex(triggerId, targetIndex)\n\n if #sexualEffects > 0 then\n if #lines > 0 then\n table.insert(lines, \"\")\n end\n\n table.insert(lines, \"#### Imposed Sexual Effects\")\n\n for _, effectText in ipairs(sexualEffects) do\n table.insert(lines, \"- \" .. effectText)\n end\n end\n end\n\n local showVirginity = true\n local firstExperienceFlags = getFirstExperienceSlot(triggerId, targetIndex)\n\n showVirginity = firstExperienceFlags ~= \"\"\n\n if showVirginity == true then\n if #lines > 0 then\n table.insert(lines, \"\")\n end\n \n table.insert(lines, \"#### Virginity\")\n\n for _, def in ipairs(defs) do\n local flagValue = \"\"\n local contentValue = \"\"\n\n local contentList = splitLiteral(safeGet(triggerId, def.contentKey, \"\"), \"|\")\n\n flagValue = getFirstExperienceFlagByPosition(triggerId, targetIndex, def.position)\n contentValue = trim(contentList[targetIndex] or \"\")\n\n local statusText = \"No Experienced\"\n\n if flagValue == constFlag.yes then\n statusText = \"Experienced\"\n\n if contentValue ~= \"\" then\n local dateText, sourceText = contentValue:match(\"^%s*([^,]+)%s*,%s*(.*)$\")\n\n if dateText and sourceText then\n contentValue = formatDateTextWithHyphen(dateText) .. \", \" .. sourceText\n end\n\n statusText = statusText .. \" (\" .. contentValue .. \")\"\n end\n end\n\n table.insert(lines, \"- \" .. def.label .. \": \" .. statusText)\n end\n end\n\n local currentDateText = trim(safeGet(triggerId, \"hr_LastDate\", \"\"))\n local currentDateTable = parseDateText(currentDateText)\n\n local lastPeriodText = \"\"\n local targetKey = \"\"\n\n local periodList = splitLiteral(safeGet(triggerId, \"hr_LastPeriod\", \"\"), \"|\")\n lastPeriodText = trim(periodList[targetIndex] or \"\")\n\n local heroineCodeList = getHeroineCodeList(triggerId)\n targetKey = trim(heroineCodeList[targetIndex] or \"\")\n\n local cycleFlag = getCycleFlagByLastPeriod(triggerId, targetKey, currentDateText, lastPeriodText)\n\n if cycleFlag ~= nil then\n local info = cycleInfo[cycleFlag] or cycleInfo[constCycle.safe]\n\n local statusTitle = \"#### Reproductive Status\"\n\n local lastPeriodDisplay = formatDateTextWithHyphen(lastPeriodText)\n if lastPeriodDisplay == \"\" then\n lastPeriodDisplay = \"Unknown\"\n end\n\n if #lines > 0 then\n table.insert(lines, \"\")\n end\n table.insert(lines, statusTitle)\n\n if cycleFlag == constCycle.pregnant then\n local pregnancyLines = buildPregnancyStatusLines(targetKey, currentDateTable)\n\n for _, line in ipairs(pregnancyLines) do\n table.insert(lines, line)\n end\n else\n table.insert(lines, \"- Pregnancy Status: Not Pregnant\")\n table.insert(lines, \"- Last Period: \" .. lastPeriodDisplay)\n\n local phaseTitle = info.title\n if cycleFlag == constCycle.menstruation then\n local periodStartDate = parseDateText(lastPeriodText)\n local periodEndDate = addDaysToDate(periodStartDate, 1)\n\n if periodEndDate then\n phaseTitle = phaseTitle .. \" (ends \" .. formatDateTextWithHyphen(formatDateText(periodEndDate)) .. \")\"\n end\n end\n\n table.insert(lines, \"- Current Phase: \" .. phaseTitle)\n\n if trim(info.text or \"\") ~= \"\" then\n table.insert(lines, \"- Cycle Status: \" .. info.text)\n end\n end\n end\n\n return table.concat(lines, \"\\n\")\nend\n\n-- ==========================================\n-- 흥분 반응\n-- ==========================================\nfunction buildPhysiologicalArousalPrivateProfileMap(triggerId, targetNameMap)\n local out = {}\n local arousalList = {}\n local arousalIndexByName = {}\n\n local function normalizeKey(text)\n text = trim(tostring(text or \"\"))\n text = text:lower()\n text = text:gsub(\"%s+\", \" \")\n return trim(text)\n end\n\n local function getLatestCharMessageText()\n local history = getFullChat(triggerId)\n\n if not history or #history <= 0 then\n return \"\"\n end\n\n for i = #history, 1, -1 do\n if history[i].role == \"char\" then\n return tostring(history[i].data or \"\")\n end\n end\n\n return \"\"\n end\n\n local function arousalLevelToState(levelText)\n local map = {\n [\"minimal\"] = constArousalState.minimal,\n [\"low\"] = constArousalState.low,\n [\"subtle\"] = constArousalState.subtle,\n [\"mild\"] = constArousalState.mild,\n [\"clear\"] = constArousalState.clear,\n [\"sustained\"] = constArousalState.sustained,\n [\"strong\"] = constArousalState.strong,\n [\"very strong\"] = constArousalState.veryStrong,\n [\"overwhelming\"] = constArousalState.overwhelming,\n [\"peak\"] = constArousalState.peak,\n }\n\n return map[normalizeKey(levelText)] or constArousalState.none\n end\n\n local function getStoredArousalResponseType(nameText)\n nameText = trim(tostring(nameText or \"\"))\n\n if nameText == \"\" then\n return constArousalResponse.none\n end\n\n local heroineCodeList = getHeroineCodeList(triggerId)\n local responseList = splitLiteral(safeGet(triggerId, \"hr_ArousalResponse\", \"\"), \"|\")\n\n for i, code in ipairs(heroineCodeList) do\n local heroineName = trim(getCharacterNameByCode(triggerId, code) or \"\")\n\n if heroineName == nameText then\n local value = tonumber(trim(responseList[i] or \"\")) or constArousalResponse.none\n\n if value >= constArousalResponse.pure\n and value <= constArousalResponse.hedonist\n then\n return value\n end\n\n return constArousalResponse.none\n end\n end\n\n return constArousalResponse.none\n end\n\n local function normalizeReception(receptionText)\n local key = normalizeKey(receptionText)\n\n if key == \"accepted\" then\n return \"accepted\"\n elseif key == \"rejected\" then\n return \"rejected\"\n end\n\n return \"\"\n end\n\n local function getBaseStateText(arousalState)\n arousalState = tonumber(arousalState) or constArousalState.none\n\n if arousalState == constArousalState.peak then\n return \"Overwhelming physiological arousal, with body control greatly reduced.\"\n elseif arousalState == constArousalState.overwhelming then\n return \"Overwhelming physiological arousal, with body control greatly reduced.\"\n elseif arousalState == constArousalState.veryStrong then\n return \"Very strong physiological arousal; composure may waver.\"\n elseif arousalState == constArousalState.strong then\n return \"Strong physiological response; movement slightly unsteady.\"\n elseif arousalState == constArousalState.sustained then\n return \"Sustained physiological arousal; posture visibly affected.\"\n elseif arousalState == constArousalState.clear then\n return \"Clear physiological signs, such as steadier breathing changes.\"\n elseif arousalState == constArousalState.mild then\n return \"Mild but noticeable physiological arousal.\"\n elseif arousalState == constArousalState.subtle then\n return \"Mild arousal; subtle physiological response; breathing may shift slightly.\"\n elseif arousalState == constArousalState.low then\n return \"Low arousal; very slight internal tension or warmth.\"\n elseif arousalState == constArousalState.minimal then\n return \"Minimal arousal; barely noticeable physiological response.\"\n end\n\n return \"\"\n end\n\n local function getExtraText(arousalState, responseType, reception)\n arousalState = tonumber(arousalState) or constArousalState.none\n responseType = tonumber(responseType) or constArousalResponse.none\n\n if arousalState == constArousalState.none then\n return \"\"\n end\n\n if responseType == constArousalResponse.none then\n return \"\"\n end\n\n if reception ~= \"accepted\" and reception ~= \"rejected\" then\n return \"\"\n end\n\n local responseMap = nil\n\n if reception == \"accepted\" then\n responseMap = {\n [constArousalResponse.pure] = \"Soft embarrassment around an unfamiliar warmth.|Shy awareness of a gentle bodily response.|Modest confusion around intimate sensation.|Flustered composure under close attention.|Warm arousal beneath careful modesty.|Clear desire within shy restraint.|Strong sensation wrapped in bashful tension.|Very intense arousal beneath delicate embarrassment.|Overpowering warmth against modest composure.|Peak arousal with bashful desire laid bare.\",\n [constArousalResponse.cool] = \"Calm awareness of an inconvenient warmth.|Quiet recognition of bodily response under composed control.|Controlled arousal behind a cool expression.|Steady composure around intimate stimulation.|Clear desire beneath deliberate restraint.|Warm response under disciplined calm.|Strong arousal within refined self-control.|Very intense sensation beneath a controlled exterior.|Overpowering arousal against polished composure.|Peak arousal beneath cool poise and shaken control.\",\n [constArousalResponse.tsundere] = \"Irritated embarrassment around an inconvenient warmth.|Flustered annoyance at a pleasant bodily response.|Sharp denial wrapped around intimate awareness.|Proud resistance mixed with obvious sensitivity.|Heated arousal beneath stubborn refusal to admit it.|Clear desire hidden behind prickly defiance.|Strong sensation against embarrassed pride.|Very intense arousal beneath a fiercely guarded expression.|Overpowering warmth with reluctant honesty exposed.|Peak arousal with pride stripped down to bare desire.\",\n [constArousalResponse.tyrant] = \"Imperious irritation at a body that reacts too honestly.|Commanding composure around inconvenient warmth.|Proud displeasure beside unmistakable arousal.|Dominant poise over intimate sensitivity.|Clear desire beneath haughty self-control.|Heated response beneath queenly arrogance.|Strong arousal locked behind domineering pride.|Very intense sensation against regal composure.|Overpowering warmth against imperious control.|Peak arousal with commanding pride laid bare.\",\n [constArousalResponse.bravado] = \"Forced confidence over a small intimate spark.|Showy composure around unfamiliar warmth.|Playful bravado over shy awareness.|Confident words beside obvious sensitivity.|Noticeable arousal beneath a bold act.|Clear desire beneath theatrical confidence.|Strong sensation under vulnerable inexperience.|Very intense arousal beneath strained bravado.|Overpowering warmth beneath an unsteady practiced act.|Peak arousal with the façade torn away.\",\n [constArousalResponse.timid] = \"Quiet confusion around a gentle inner warmth.|Shy uncertainty toward unfamiliar closeness.|Soft bewilderment beside a pleasant bodily response.|Flustered sensitivity under intimate attention.|Noticeable arousal paired with nervous sweetness.|Clear desire held in anxious hesitation.|Strong sensation wrapped in timid vulnerability.|Very intense arousal beneath fragile composure.|Overpowering warmth across nervous awareness.|Peak arousal with timid desire exposed.\",\n [constArousalResponse.denpa] = \"A strange warmth with a dreamlike texture.|Soft disorientation around intimate sensation.|Unreal awareness of pleasant bodily response.|Hazy sensitivity under close attention.|Noticeable arousal with distant, dreamy focus.|Clear desire filtered through odd perception.|Strong sensation wrapped in unreal fascination.|Very intense arousal beneath blurred awareness.|Overpowering warmth across ordinary focus.|Peak arousal with perception drowned in intimate sensation.\",\n [constArousalResponse.broken] = \"Quiet sensitivity beneath fragile composure.|Soft warmth received through subdued awareness.|Uneasy tenderness around a bodily response.|Fragile awareness under intimate closeness.|Noticeable arousal held in quiet vulnerability.|Clear desire folded into subdued tenderness.|Strong sensation beneath a worn-down guard.|Very intense arousal beneath fragile openness.|Overpowering warmth inside delicate, yielding calm.|Peak arousal with every guard gone quiet.\",\n [constArousalResponse.hedonist] = \"A pleased spark beneath brazen interest.|Open amusement at the body's eager response.|Shameless awareness of intimate warmth.|Heated confidence under close attention.|Noticeable arousal met with bold enjoyment.|Clear desire held with hungry anticipation.|Strong sensation beneath playful greed.|Very intense arousal under reckless delight.|Overpowering pleasure over loosened restraint.|Peak arousal with raw pleasure over every restraint.\",\n }\n else\n responseMap = {\n [constArousalResponse.pure] = \"A faint sense that something feels strange without understanding why.|Mild confusion at unfamiliar internal sensations.|Embarrassed awareness of reactions that feel improper.|Increasing self-consciousness about the body's response.|Unwanted arousal becoming difficult to dismiss.|Heat and tension lingering despite attempts to ignore it.|Strong awareness of sensations that feel deeply inappropriate.|Intense arousal causing mounting shame and confusion.|Overpowering bodily response conflicting with strong modesty.|Peak arousal leaving no way to ignore what is happening.\",\n [constArousalResponse.cool] = \"A faint awareness of something intrusive and unwelcome.|Mild irritation at unfamiliar internal sensations.|Cold recognition of unwanted bodily response.|Controlled displeasure at reactions that refuse to stop.|Unwanted arousal provoking restrained hostility.|Persistent sensations met with rigid suppression.|Strong arousal contained through sheer will.|Very intense response clashing with unyielding resistance.|Overpowering sensations forcing cracks in strict composure.|Peak arousal overwhelming the body despite absolute rejection.\",\n [constArousalResponse.tsundere] = \"Immediate irritation at something intrusive.|Annoyance at unfamiliar internal tension.|Angry denial of unwanted sensations.|Growing frustration at reactions that refuse to stop.|Unwanted arousal provoking sharp hostility.|Persistent sensations fueling stubborn resistance.|Strong arousal clashing with fierce refusal to acknowledge it.|Very intense response breaking through proud composure.|Overpowering sensations forcing involuntary honesty to slip through.|Peak arousal overwhelming pride, raw desire exposed despite rejection.\",\n [constArousalResponse.tyrant] = \"Immediate contempt for something offensive.|Irritation at unwelcome internal tension.|Cold fury at an indecent bodily reaction.|Disgust at sensations deemed beneath dignity.|Unwanted arousal provoking burning indignation.|Persistent sensations met with domineering denial.|Strong arousal clashing with ruthless self-control.|Very intense response fueling humiliated rage.|Overpowering sensations tearing through imperious composure.|Peak arousal shattering dominance, raw vulnerability exposed against her will.\",\n [constArousalResponse.bravado] = \"A flicker of tension hidden behind forced confidence.|Mild arousal denied with showy bravado.|Unfamiliar warmth provoking uneasy self-awareness.|Growing stimulation shaking the practiced façade.|Noticeable arousal met with strained composure.|Clear bodily response becoming hard to dismiss.|Strong sensations exposing underlying inexperience.|Very intense arousal overwhelming false confidence.|Overpowering stimulation breaking through the defensive act.|Peak arousal collapsing the façade, raw vulnerability laid bare.\",\n [constArousalResponse.timid] = \"A faint sense of something odd without concern.|Mild confusion at unfamiliar internal sensations.|Uncertain warmth noticed without understanding.|Growing discomfort that feels strangely puzzling.|Noticeable arousal causing flustered confusion.|Clear bodily response that is hard to interpret.|Strong sensations creating anxious bewilderment.|Very intense stimulation overwhelming simple composure.|Overpowering arousal causing panicked confusion.|Peak arousal flooding awareness, leaving only disoriented sensation.\",\n [constArousalResponse.denpa] = \"A faint sense of something strangely out of place.|Mild confusion at unfamiliar internal sensations.|Uncertain awareness of warmth that feels unreal.|Growing disorientation as sensations blur together.|Unwanted arousal perceived as oddly distant.|Persistent sensations distorting normal perception.|Strong arousal creating a sense of unreality.|Very intense response overwhelming coherent thought.|Overpowering sensations dissolving clear awareness.|Peak arousal consuming perception, reality fading under the flood of sensation.\",\n [constArousalResponse.broken] = \"A faint discomfort noticed but ignored.|Mild tension met with quiet resignation.|Unwelcome warmth provoking uneasy self-awareness.|Growing stimulation bringing subdued distress.|Noticeable arousal becoming hard to ignore.|Clear bodily response causing quiet despair.|Strong sensations draining fragile composure.|Very intense arousal overwhelming passive resistance.|Overpowering stimulation crushing what little resolve remained.|Peak arousal flooding everything, leaving only helpless surrender to sensation.\",\n [constArousalResponse.hedonist] = \"A faint spark of stimulation beneath irritation.|Mild arousal acknowledged with coarse amusement.|Unwanted warmth stirring a shameless awareness.|Growing stimulation met with brazen acceptance.|Noticeable arousal provoking heated excitement.|Clear bodily response embraced despite hostility.|Strong sensations fueling crude anticipation.|Very intense stimulation pushing toward reckless abandon.|Overpowering arousal drowning out restraint.|Peak arousal consuming the body, raw pleasure overwhelming everything.\",\n }\n end\n\n local raw = responseMap[responseType]\n\n if not raw then\n return \"\"\n end\n\n local indexMap = {\n [constArousalState.minimal] = 1,\n [constArousalState.low] = 2,\n [constArousalState.subtle] = 3,\n [constArousalState.mild] = 4,\n [constArousalState.clear] = 5,\n [constArousalState.sustained] = 6,\n [constArousalState.strong] = 7,\n [constArousalState.veryStrong] = 8,\n [constArousalState.overwhelming] = 9,\n [constArousalState.peak] = 10,\n }\n\n local parts = splitLiteral(raw, \"|\")\n return trim(parts[indexMap[arousalState] or 1] or \"\")\n end\n\n local function buildProfileText(arousalState, responseType, reception)\n local baseText = getBaseStateText(arousalState)\n\n if baseText == \"\" then\n return \"\"\n end\n\n local extraText = getExtraText(arousalState, responseType, reception)\n\n if extraText ~= \"\" then\n baseText = baseText .. \" \" .. extraText\n end\n\n return \"#### Physiological Arousal State\\n- \" .. baseText\n end\n\n local function tryParseRecord(recordRaw)\n recordRaw = trim(tostring(recordRaw or \"\"))\n\n if recordRaw == \"\" then\n return\n end\n\n recordRaw = trim(recordRaw:gsub(\"^%[\", \"\"):gsub(\"%]$\", \"\"))\n\n local fields = parseOrderedFieldsWithFallback(recordRaw, {\n \"Name\",\n \"Arousal\",\n \"Arousal Reception\",\n })\n\n local nameText = trim(fields[\"name\"] or \"\")\n local arousalState = arousalLevelToState(fields[\"arousal\"] or \"\")\n\n if nameText == \"\" or arousalState == constArousalState.none then\n return\n end\n\n if targetNameMap and targetNameMap[nameText] ~= true then\n return\n end\n\n local responseType = getStoredArousalResponseType(nameText)\n local reception = normalizeReception(fields[\"arousal reception\"] or \"\")\n local profileText = buildProfileText(arousalState, responseType, reception)\n\n local arousalData = {\n name = nameText,\n arousalState = arousalState,\n arousalResponseType = responseType,\n arousalReception = reception,\n profileText = profileText,\n }\n\n local oldIndex = arousalIndexByName[nameText]\n\n if oldIndex then\n arousalList[oldIndex] = arousalData\n else\n table.insert(arousalList, arousalData)\n arousalIndexByName[nameText] = #arousalList\n end\n\n if profileText ~= \"\" then\n out[nameText] = profileText\n end\n end\n\n local msg = getLatestCharMessageText()\n msg = maskThoughtBlocksForParsing(msg)\n\n if msg == \"\" then\n return out, arousalList\n end\n\n for body in msg:gmatch(\"<Physiological Arousal State>%s*([%s%S]-)%s*</Physiological Arousal State>\") do\n local hasBracketEntry = false\n\n for recordRaw in tostring(body):gmatch(\"%b[]\") do\n hasBracketEntry = true\n tryParseRecord(recordRaw)\n end\n\n if hasBracketEntry ~= true then\n tryParseRecord(body)\n end\n end\n\n return out, arousalList\nend\n\n-- ==========================================\n-- 캐릭터 검색\n-- ==========================================\nfunction searchMentionedCharacter(triggerId)\n local heroineRaw = trim(tostring(getRecentMentionedCharacterList(triggerId) or \"\"))\n\n -- ==========================================\n -- 활성화 캐릭터 표시용\n -- 히로인만 \", \" 표시\n -- ==========================================\n local mentionedDisplayList = {}\n\n for _, name in ipairs(splitLiteral(heroineRaw, \"|\")) do\n name = trim(name)\n\n if name ~= \"\" then\n table.insert(mentionedDisplayList, name)\n end\n end\n\n setChatVar(triggerId, \"hr_Mentioned_List\", table.concat(mentionedDisplayList, \", \"))\n\n -- ==========================================\n -- 활성화 히로인 맵\n -- ==========================================\n local activeHeroineMap = {}\n\n for _, name in ipairs(splitLiteral(heroineRaw, \"|\")) do\n name = trim(name)\n\n if name ~= \"\" then\n activeHeroineMap[name] = true\n end\n end\n\n local heroineCodeList = getHeroineCodeList(triggerId)\n\n -- ==========================================\n -- 활성 히로인 캐릭터 목록\n -- ==========================================\n local activeHeroineTargetList = {}\n local activeHeroineTargetMap = {}\n\n do\n local function addActiveHeroineTarget(name)\n name = trim(tostring(name or \"\"))\n\n if name == \"\" then\n return\n end\n\n if activeHeroineTargetMap[name] == true then\n return\n end\n\n activeHeroineTargetMap[name] = true\n table.insert(activeHeroineTargetList, name)\n end\n\n for _, code in ipairs(heroineCodeList) do\n local name = trim(getCharacterNameByCode(triggerId, code) or \"\")\n\n if name ~= \"\" and activeHeroineMap[name] == true then\n addActiveHeroineTarget(name)\n end\n end\n\n setChatVar(triggerId, \"hr_List_Heroine\", table.concat(activeHeroineTargetList, \", \"))\n\n setChatVar(triggerId, \"hr_SexualEffect_Owned\", buildSexualEffectOwnedText(triggerId, table.concat(activeHeroineTargetList, \", \")))\n end\n\n local arousalProfileMap, arousalList = buildPhysiologicalArousalPrivateProfileMap(triggerId, activeHeroineTargetMap)\n local maxArousal = constArousalState.none\n\n for _, arousalData in ipairs(arousalList) do\n if arousalData.arousalState > maxArousal then\n maxArousal = arousalData.arousalState\n end\n end\n\n setChatVar(triggerId, \"hr_Moan_ArousalMax\", tostring(maxArousal))\n\n -- ==========================================\n -- 초기 설정 대상 목록\n -- 활성화 히로인 중 hr_ArousalResponse가 비어있는 캐릭터\n -- ==========================================\n do\n local initTargetList = {}\n local arousalResponseList = splitLiteral(safeGet(triggerId, \"hr_ArousalResponse\", \"\"), \"|\")\n\n for i, code in ipairs(heroineCodeList) do\n local name = trim(getCharacterNameByCode(triggerId, code) or \"\")\n\n if name ~= \"\"\n and activeHeroineMap[name] == true\n and trim(arousalResponseList[i] or \"\") == \"\"\n then\n table.insert(initTargetList, name)\n end\n end\n\n setChatVar(triggerId, \"hr_List_Init\", table.concat(initTargetList, \", \"))\n end\n\n -- ==========================================\n -- 첫경험 첫 세팅 대상 목록\n -- 활성화 히로인 중 hr_FirstExperiences의 해당 칸이 비어있는 캐릭터만 대상\n -- ==========================================\n local firstCheckTargetMap = {}\n\n do\n local firstExperienceList = getFirstExperienceSlotList(triggerId)\n local firstCheckTargetList = {}\n\n for i, code in ipairs(heroineCodeList) do\n local name = trim(getCharacterNameByCode(triggerId, code) or \"\")\n\n if name ~= \"\" and activeHeroineMap[name] == true then\n local firstExperienceFlags = getFirstExperienceSlotFromList(firstExperienceList, i)\n\n if firstExperienceFlags == \"\" then\n table.insert(firstCheckTargetList, name)\n firstCheckTargetMap[name] = true\n end\n end\n end\n\n setChatVar(triggerId, \"hr_List_FirstCheck\", table.concat(firstCheckTargetList, \", \"))\n end\n\n -- ==========================================\n -- 첫경험 대상 목록\n -- 단, 첫경험 첫 세팅 대상에 들어간 캐릭터는 제외\n -- ==========================================\n local firstExperienceList = getFirstExperienceSlotList(triggerId)\n\n local firstTargetList = {}\n local hasPriorSexualExperience = false\n\n for i, code in ipairs(heroineCodeList) do\n local name = trim(getCharacterNameByCode(triggerId, code) or \"\")\n\n if name ~= \"\"\n and activeHeroineMap[name] == true\n and firstCheckTargetMap[name] ~= true\n then\n local firstExperienceFlags = getFirstExperienceSlotFromList(firstExperienceList, i)\n local hasKiss = isFirstExperienceFlagYes(firstExperienceFlags, constFirstExperience.kiss)\n local hasOral = isFirstExperienceFlagYes(firstExperienceFlags, constFirstExperience.oral)\n local hasVaginal = isFirstExperienceFlagYes(firstExperienceFlags, constFirstExperience.vaginal)\n local hasAnal = isFirstExperienceFlagYes(firstExperienceFlags, constFirstExperience.anal)\n\n if hasKiss or hasOral or hasVaginal or hasAnal then\n hasPriorSexualExperience = true\n end\n\n if not (hasKiss and hasOral and hasVaginal and hasAnal) then\n table.insert(firstTargetList, name)\n end\n end\n end\n\n setChatVar(triggerId, \"hr_List_FirstExperience\", table.concat(firstTargetList, \", \"))\n\n -- ==========================================\n -- 임신 리스크 대상 목록\n -- ==========================================\n local hasActiveFertileHeroine = false\n\n do\n local conceptionRiskList = {}\n local currentDateText = trim(safeGet(triggerId, \"hr_LastDate\", \"\"))\n\n local periodList = splitLiteral(safeGet(triggerId, \"hr_LastPeriod\", \"\"), \"|\")\n\n for i, code in ipairs(heroineCodeList) do\n local name = trim(getCharacterNameByCode(triggerId, code) or \"\")\n\n if name ~= \"\" and activeHeroineMap[name] == true then\n local lastPeriodText = trim(periodList[i] or \"\")\n local cycleFlag = getCycleFlagByLastPeriod(triggerId, code, currentDateText, lastPeriodText)\n\n if isConceptionRiskCycle(cycleFlag) then\n table.insert(conceptionRiskList, name)\n hasActiveFertileHeroine = true\n end\n end\n end\n\n setChatVar(triggerId, \"hr_List_ConceptionRisk\", table.concat(conceptionRiskList, \", \"))\n end\n\n -- ==========================================\n -- 사후피임 대상 목록\n -- 현재 임신 리스크가 있고 pending이 아닌 활성화 히로인\n -- ==========================================\n do\n local contraceptionList = {}\n\n local riskList = splitLiteral(safeGet(triggerId, \"hr_ConceptionRisk\", \"\"), \"|\")\n\n for i, code in ipairs(heroineCodeList) do\n local name = trim(getCharacterNameByCode(triggerId, code) or \"\")\n\n if name ~= \"\" and activeHeroineMap[name] == true then\n local parts = splitLiteral(riskList[i] or \"\", \"^\")\n local risk = clamp(extractInt(parts[1] or \"0\"), 0, 100)\n local pendingFlag = trim(parts[3] or \"\")\n\n if risk > 0 and pendingFlag ~= constFlag.yes and getCharacterPregnancyRecordByCode(triggerId, code) == \"\" then\n table.insert(contraceptionList, name)\n end\n end\n end\n\n setChatVar(triggerId, \"hr_List_Contraception\", table.concat(contraceptionList, \", \"))\n end\n\n -- ==========================================\n -- 임신 종료 기록 대상 목록\n -- 활성화 히로인: 전개 중 임신 종료 기록 대상\n -- 비활성 히로인: 출산 예정일이 지난 경우만 자동 출산 기록 대상\n -- ==========================================\n do\n local pregnancyEndList = {}\n local birthList = {}\n local birthNameList = {}\n\n local currentDateText = trim(safeGet(triggerId, \"hr_LastDate\", \"\"))\n local currentDateTable = parseDateText(currentDateText)\n\n for i, code in ipairs(heroineCodeList) do\n local name = trim(getCharacterNameByCode(triggerId, code) or \"\")\n\n if name ~= \"\" then\n local pregnancyRecord = getCharacterPregnancyRecordByCode(triggerId, code)\n\n if trim(pregnancyRecord or \"\") ~= \"\" then\n if activeHeroineMap[name] == true then\n table.insert(pregnancyEndList, name)\n else\n local pregnancyParts = splitLiteral(pregnancyRecord, \"^\")\n\n local pregnancyDateRaw = trim(pregnancyParts[1] or \"\")\n local fatherRaw = trim(pregnancyParts[2] or \"\")\n local daysTotal = tonumber(pregnancyParts[3] or \"0\") or 0\n\n local pregnancyDateTable = parseDateText(pregnancyDateRaw)\n\n if pregnancyDateTable and currentDateTable and daysTotal > 0 and fatherRaw ~= \"\" then\n local elapsedDays = diffDaysBetweenDateTables(pregnancyDateTable, currentDateTable)\n\n if elapsedDays ~= nil and elapsedDays >= daysTotal then\n local deliveryDateTable = addDaysToDate(pregnancyDateTable, daysTotal)\n\n if deliveryDateTable then\n local deliveryDateText = formatDateTextWithHyphen(formatDateText(deliveryDateTable))\n\n table.insert(birthList, name .. \": \" .. deliveryDateText .. \", \" .. fatherRaw .. \"'s offspring\")\n table.insert(birthNameList, name)\n end\n end\n end\n end\n end\n end\n end\n\n setChatVar(triggerId, \"hr_List_PregnancyEnd\", table.concat(pregnancyEndList, \", \"))\n\n if #birthList > 0 then\n local birthGuide =\n \"### Offscreen Delivery Rules\\n\" ..\n \"- Assume \" .. table.concat(birthNameList, \", \") .. \" delivered or laid on their listed dates, and write the entry using each listed date and parentage.\\n\" ..\n \"- Since their delivery or laying has already happened, these characters are not currently pregnant.\\n\" ..\n \"- They do not need to appear or be mentioned in the narration.\"\n\n setChatVar(triggerId, \"hr_List_Birth\", \"\\n\" .. table.concat(birthList, \"\\n\") .. \"\\n\\n\" .. birthGuide)\n else\n setChatVar(triggerId, \"hr_List_Birth\", \"\")\n end\n end\n\n -- ==========================================\n -- 캐릭터 정보 표시용\n -- 활성화 히로인\n -- ==========================================\n do\n local characterInfoParts = {}\n\n for i, code in ipairs(heroineCodeList) do\n local name = trim(getCharacterNameByCode(triggerId, code) or \"\")\n\n if name ~= \"\" and activeHeroineMap[name] == true then\n local profileParts = {}\n\n local sexualProfile = buildSexualProfile(triggerId, i)\n if sexualProfile ~= \"\" then\n table.insert(profileParts, sexualProfile)\n end\n\n local arousalProfile = arousalProfileMap[name] or \"\"\n if arousalProfile ~= \"\" then\n table.insert(profileParts, arousalProfile)\n end\n\n if #profileParts > 0 then\n table.insert(\n characterInfoParts,\n \"### \" .. name .. \"\\n\\n\" .. table.concat(profileParts, \"\\n\\n\")\n )\n end\n end\n end\n\n if #characterInfoParts > 0 then\n local directiveParts = {}\n\n local referenceDateText = formatDateTextWithHyphen(safeGet(triggerId, \"hr_LastDate\", \"\"))\n\n if referenceDateText ~= \"\" then\n local referenceDateBlock =\n \"### Reproductive Status Reference Date: \" .. referenceDateText .. \"\\n\" ..\n --\"- For heroine characters, Reproductive Status is based on this reference date; recalculate it according to any elapsed story time.\"\n \"- For heroine characters, Reproductive Status is based on this reference date. If the date changes during this response, use the changed date as the basis before describing anything related to reproductive status.\"\n\n table.insert(directiveParts, referenceDateBlock)\n end\n\n if hasActiveFertileHeroine then\n local fertileWindowRiskBlock =\n \"### Fertile-Window Pregnancy Risk Response\\n\" ..\n \"- For any active heroine currently in the fertile window, if a situation arises where intravaginal ejaculation is likely, she may show conscious or instinctive pregnancy-risk avoidance, including intense refusal, active resistance, desperate pleading, or bargaining and de-escalation.\"\n\n if tonumber(safeGet(triggerId, \"hr_Choice_Flag\", \"0\")) == constChoice.choice_HScene_Event then\n fertileWindowRiskBlock = fertileWindowRiskBlock .. \"\\n\" ..\n \"- Conversely, reproductively capable opposing sides instinctively feel an urge to impregnate fertile-window heroines, whether they consciously recognize it or not.\"\n end\n\n table.insert(directiveParts, fertileWindowRiskBlock)\n end\n\n if hasPriorSexualExperience then\n local priorExperienceBlock =\n \"### Prior Sexual Experience Continuity\\n\" ..\n \"- For heroine characters, before describing any related sexual act or body part, you must check the Confirmed Prior Experiences below and avoid clichéd first-time or inexperience wording.\\n\" ..\n \"- The following entries mark sexual experiences the heroine has already had in the past. Whenever a listed act or its corresponding body part is referenced, the narration must treat it as a prior experience, not as first-time, never-before, untouched, unused, innocence-coded, virgin-like, or completely unfamiliar wording (e.g., \\\"her first time\\\", \\\"never done this before\\\", \\\"untouched\\\", \\\"unused\\\", \\\"pure\\\", \\\"innocent\\\", \\\"virgin-like\\\", \\\"completely new to her\\\"). Instead, describe it as something she has already experienced before.\\n\" ..\n \"- Other characters may mistakenly assume otherwise if they do not know her past, but the narration must treat it as a prior experience, while the heroine's own thoughts may reflect ignorance or uncertainty only if she lacks memory or awareness of the event.\"\n\n table.insert(directiveParts, priorExperienceBlock)\n end\n\n local privateStateParts = {}\n\n if #directiveParts > 0 then\n table.insert(\n privateStateParts,\n \"## Heroine State Reference Rules\\n\\n\" .. table.concat(directiveParts, \"\\n\\n\")\n )\n end\n\n table.insert(\n privateStateParts,\n \"## Heroine State\\n\\n\" .. table.concat(characterInfoParts, \"\\n\\n\")\n )\n\n local privateStateText = table.concat(privateStateParts, \"\\n\\n\")\n setChatVar(triggerId, \"hr_Heroine_Profile\", privateStateText)\n else\n setChatVar(triggerId, \"hr_Heroine_Profile\", \"\")\n end\n\n end\n\n -- ==========================================\n -- Memory 표시용\n -- 활성화 캐릭터 전부 사용\n -- ==========================================\n do\n local memoryParts = {}\n\n for _, name in ipairs(mentionedDisplayList) do\n name = trim(name)\n\n if name ~= \"\" then\n local code = getCharacterCodeByName(triggerId, name)\n\n if code ~= \"\" then\n local memoryProfile = buildMemoryProfile(triggerId, code, name)\n\n if memoryProfile ~= \"\" then\n table.insert(memoryParts, memoryProfile)\n end\n end\n end\n end\n\n if #memoryParts > 0 then\n setChatVar(triggerId, \"hr_Memory_Display\", \"## Story Log\\n\\n\" .. table.concat(memoryParts, \"\\n\\n\"))\n else\n setChatVar(triggerId, \"hr_Memory_Display\", \"\")\n end\n end\nend\n\nfunction getRecentMentionedCharacterList(triggerId)\n local history = getFullChat(triggerId)\n\n if not history or #history <= 0 then\n return \"\"\n end\n\n -- ==========================================\n -- 1) 아래에서부터 최근 채팅 내용 수집\n -- role은 user/char 구분하지 않음\n -- ==========================================\n local recentTexts = {}\n local count = 0\n\n for i = #history, 1, -1 do\n local data = history[i] and history[i].data\n\n if type(data) == \"string\" and trim(data) ~= \"\" then\n table.insert(recentTexts, data)\n count = count + 1\n\n if count >= 4 then\n break\n end\n end\n end\n\n if #recentTexts <= 0 then\n return \"\"\n end\n\n local bodySearchText = table.concat(recentTexts, \"\\n\")\n\n bodySearchText = bodySearchText:gsub(\n \"<Physiological Arousal State>[%s%S]-</Physiological Arousal State>\",\n \"\"\n )\n\n -- ==========================================\n -- 3) 히로인만 수집\n -- code 순서대로 돌면서 해당 code의 검색어 슬롯을 가져옴\n -- ==========================================\n local heroineFound = {}\n local heroineFoundMap = {}\n\n local function collectMentionedNames(codeList, foundList, foundMap)\n for _, code in ipairs(codeList or {}) do\n local name = trim(getCharacterNameByCode(triggerId, code) or \"\")\n local searchValue = getCharacterSearchByCode(triggerId, code)\n\n if name ~= \"\" and searchValue ~= \"\" and not foundMap[name] then\n local mentioned = false\n\n for _, keyword in ipairs(splitLiteral(searchValue, \"^\")) do\n keyword = trim(keyword)\n\n if keyword ~= \"\" and bodySearchText:find(keyword, 1, true) ~= nil then\n mentioned = true\n break\n end\n end\n\n if mentioned then\n table.insert(foundList, name)\n foundMap[name] = true\n end\n end\n end\n end\n\n collectMentionedNames(getHeroineCodeList(triggerId), heroineFound, heroineFoundMap)\n\n return table.concat(heroineFound, \"|\")\nend\n\n-- ==========================================\n-- 첫경험 수동 설정\n-- ==========================================\nfunction inputFirstExperience(triggerId, kind)\n local defs = {\n kiss = {\n label = \"첫 키스\",\n position = constFirstExperience.kiss,\n contentKey = \"hr_FirstKiss_Content\",\n },\n oral = {\n label = \"첫 펠라치오\",\n position = constFirstExperience.oral,\n contentKey = \"hr_FirstOral_Content\",\n },\n vaginal = {\n label = \"첫 질내섹스\",\n position = constFirstExperience.vaginal,\n contentKey = \"hr_FirstVaginal_Content\",\n },\n anal = {\n label = \"첫 항문섹스\",\n position = constFirstExperience.anal,\n contentKey = \"hr_FirstAnal_Content\",\n },\n }\n\n local def = defs[kind]\n\n if not def then\n return\n end\n\n local actorName = \"\"\n local contentKey = \"\"\n local position = 0\n\n local selectedCode = trim(getChatVar(triggerId, \"hr_Selected_Code\"))\n actorName = trim(getChatVar(triggerId, \"hr_Selected_Name\"))\n\n contentKey = def.contentKey\n\n local heroineCodeList = getHeroineCodeList(triggerId)\n\n for i, code in ipairs(heroineCodeList) do\n if code == selectedCode then\n position = i\n break\n end\n end\n\n if position < 1 then\n return\n end\n\n local message =\n actorName .. \"의 \" .. def.label .. \" 첫 경험 여부를 설정할 수 있습니다.\\n\\n\" ..\n \"형식: YYYY-MM-DD 또는 Pre-Story, 상대\\n\" ..\n \"예) 2026-05-14, 첫 남친\\n\" ..\n \"예) Pre-Story, 남친의 성기\\n\" ..\n \"예) Pre-Story, 딜도\\n\\n\" ..\n \"예) Pre-Story\\n\\n\" ..\n \"취소하려면 📥 불러오기 버튼을 누르세요.\"\n\n local input = alertInput(triggerId, message):await()\n\n if input == nil or input == \"\" then\n return\n end\n\n -- hr_FirstExperiences에는 해당 자리만 yes로 저장하고, content에는 입력값 그대로 저장\n setFirstExperienceFlagByPosition(triggerId, position, def.position, constFlag.yes)\n\n do\n local list = splitLiteral(safeGet(triggerId, contentKey, \"\"), \"|\")\n\n for i = #list + 1, position do\n list[i] = \"\"\n end\n\n list[position] = tostring(input)\n\n local lastUsed = 0\n\n for i = 1, #list do\n if trim(list[i] or \"\") ~= \"\" then\n lastUsed = i\n end\n end\n\n local out = {}\n\n for i = 1, lastUsed do\n out[i] = tostring(list[i] or \"\")\n end\n\n setChatVar(triggerId, contentKey, table.concat(out, \"|\"))\n end\n\n -- hr_TrackValue에는 contentKey 넣지 않음. 통합 플래그만 누적.\n do\n local rawTrack = safeGet(triggerId, \"hr_TrackValue\", \"\")\n local exists = false\n\n for _, item in ipairs(splitLiteral(rawTrack, \"|\")) do\n if trim(item or \"\") == constFirstExperience.varName then\n exists = true\n break\n end\n end\n\n if exists == false then\n if rawTrack == \"\" then\n setChatVar(triggerId, \"hr_TrackValue\", constFirstExperience.varName)\n else\n setChatVar(triggerId, \"hr_TrackValue\", rawTrack .. \"|\" .. constFirstExperience.varName)\n end\n end\n end\n\n generateHTML(triggerId)\n alertNormal(triggerId, \"✅ \" .. actorName .. \"의 \" .. def.label .. \" 경험 설정 완료\")\nend\n\nhr_first_selected_kiss = async(function(triggerId)\n inputFirstExperience(triggerId, \"kiss\")\nend)\n\nhr_first_selected_oral = async(function(triggerId)\n inputFirstExperience(triggerId, \"oral\")\nend)\n\nhr_first_selected_vaginal = async(function(triggerId)\n inputFirstExperience(triggerId, \"vaginal\")\nend)\n\nhr_first_selected_anal = async(function(triggerId)\n inputFirstExperience(triggerId, \"anal\")\nend)\n\n\n-- ==========================================\n-- 임신\n-- ==========================================\nfunction ensureLastPeriod(triggerId, baseDateText, changedVars)\n local baseDateTable = parseDateText(baseDateText)\n\n if not baseDateTable then\n return false\n end\n\n local codeList = getHeroineCodeList(triggerId)\n local targetCount = #codeList\n\n if targetCount <= 0 then\n return false\n end\n\n local currentList = splitLiteral(safeGet(triggerId, \"hr_LastPeriod\", \"\"), \"|\")\n local outputList = {}\n\n local datePool = {}\n\n for offset = 0, 27 do\n local d = addDaysToDate(baseDateTable, -offset)\n\n if d then\n table.insert(datePool, formatDateText(d))\n end\n end\n\n if #datePool <= 0 then\n return false\n end\n\n shuffleArray(datePool)\n\n local poolIndex = 1\n local changed = false\n\n local function syncPeriodValue(currentValue)\n currentValue = trim(tostring(currentValue or \"\"))\n\n -- 빈값일 때만 초기값 생성\n if currentValue == \"\" then\n if poolIndex > #datePool then\n shuffleArray(datePool)\n poolIndex = 1\n end\n\n local value = datePool[poolIndex]\n poolIndex = poolIndex + 1\n\n return value\n end\n\n -- 숫자가 아니면 상태값으로 보고 건드리지 않음\n -- 예: X, pregnant, 임신 등\n if not currentValue:match(\"^%d+$\") then\n return currentValue\n end\n\n local lastPeriodDateTable = parseDateText(currentValue)\n\n -- 숫자지만 유효한 날짜가 아니면 건드리지 않음\n if not lastPeriodDateTable then\n return currentValue\n end\n\n local elapsed = diffDaysBetweenDateTables(lastPeriodDateTable, baseDateTable)\n\n -- 미래 날짜면 건드리지 않음\n if not elapsed or elapsed < 0 then\n return formatDateText(lastPeriodDateTable)\n end\n\n -- 28일 주기로 현재 날짜 기준 가장 최근 생리 시작일로 이동\n local passedCycles = math.floor(elapsed / 28)\n\n if passedCycles <= 0 then\n return formatDateText(lastPeriodDateTable)\n end\n\n local nextDateTable = addDaysToDate(lastPeriodDateTable, passedCycles * 28)\n\n if not nextDateTable then\n return formatDateText(lastPeriodDateTable)\n end\n\n return formatDateText(nextDateTable)\n end\n\n -- 히로인 캐릭터 생리일\n for i = 1, targetCount do\n outputList[i] = syncPeriodValue(currentList[i])\n end\n\n local oldValue = safeGet(triggerId, \"hr_LastPeriod\", \"\")\n local newValue = table.concat(outputList, \"|\")\n\n if newValue ~= oldValue then\n setTrackedChatVar(triggerId, changedVars, \"hr_LastPeriod\", newValue)\n changed = true\n end\n\n return changed\nend\n\nfunction getCycleFlagByLastPeriod(triggerId, targetKey, currentDateText, lastPeriodText)\n targetKey = trim(tostring(targetKey or \"\"))\n\n -- ==========================================\n -- 임신 확정 / pending 우선 판정\n -- targetKey:\n -- \"H001\" 같은 캐릭터 코드 -> 해당 히로인 캐릭터\n -- ==========================================\n if triggerId ~= nil and targetKey ~= \"\" then\n local lowered = string.lower(targetKey)\n\n if getCharacterPregnancyRecordByCode(triggerId, targetKey) ~= \"\" then\n return constCycle.pregnant\n end\n\n local heroineCodeList = getHeroineCodeList(triggerId)\n local targetIndex = 0\n\n for i, code in ipairs(heroineCodeList) do\n if trim(code or \"\") == targetKey then\n targetIndex = i\n break\n end\n end\n\n if targetIndex > 0 then\n local conceptionRiskList = splitLiteral(safeGet(triggerId, \"hr_ConceptionRisk\", \"\"), \"|\")\n local riskParts = splitLiteral(conceptionRiskList[targetIndex] or \"\", \"^\")\n local pendingFlag = trim(riskParts[3] or \"\")\n\n if pendingFlag == constFlag.yes then\n return constCycle.pending\n end\n end\n end\n\n currentDateText = trim(tostring(currentDateText or \"\"))\n lastPeriodText = trim(tostring(lastPeriodText or \"\"))\n\n local currentDateTable = parseDateText(currentDateText)\n local lastPeriodDateTable = parseDateText(lastPeriodText)\n\n if not currentDateTable or not lastPeriodDateTable then\n return nil\n end\n\n local elapsed = diffDaysBetweenDateTables(lastPeriodDateTable, currentDateTable)\n\n if elapsed == nil or elapsed < 0 then\n return nil\n end\n\n local cycleDay = (elapsed % 28) + 1\n\n if cycleDay >= 1 and cycleDay <= 2 then\n return constCycle.menstruation\n elseif cycleDay == 9 then\n return constCycle.fertile_m5\n elseif cycleDay == 10 then\n return constCycle.fertile_m4\n elseif cycleDay == 11 then\n return constCycle.fertile_m3\n elseif cycleDay == 12 then\n return constCycle.fertile_m2\n elseif cycleDay == 13 then\n return constCycle.fertile_m1\n elseif cycleDay == 14 then\n return constCycle.ovulation\n elseif cycleDay == 15 then\n return constCycle.fertile_p1\n elseif cycleDay >= 16 and cycleDay <= 28 then\n return constCycle.post_fertile\n end\n\n return constCycle.safe\nend\n\nfunction isConceptionRiskCycle(cycleFlag)\n return cycleFlag == constCycle.fertile_m5\n or cycleFlag == constCycle.fertile_m4\n or cycleFlag == constCycle.fertile_m3\n or cycleFlag == constCycle.fertile_m2\n or cycleFlag == constCycle.fertile_m1\n or cycleFlag == constCycle.ovulation\n or cycleFlag == constCycle.fertile_p1\nend\n\nfunction getCharacterPregnancyRecordByCode(triggerId, code)\n code = trim(tostring(code or \"\"))\n\n if code == \"\" then\n return \"\"\n end\n\n local raw = safeGet(triggerId, \"hr_Pregnancy\", \"\")\n local records = splitLiteral(raw, \"|\")\n\n for _, record in ipairs(records) do\n record = trim(record)\n\n if record ~= \"\" then\n local recordCode, body = record:match(\"^%s*([^=]+)%s*=(.*)$\")\n\n recordCode = trim(recordCode or \"\")\n body = trim(body or \"\")\n\n if recordCode == code then\n return body\n end\n end\n end\n\n return \"\"\nend\n\n-- ==========================================\n-- 임신 시작 판정\n-- ==========================================\nfunction processPregnancyStartChecks(triggerId, changedVars)\n local pregnancyStartList = {}\n\n local currentDateText = trim(safeGet(triggerId, \"hr_LastDate\", \"\"))\n local currentDateTable = parseDateText(currentDateText)\n\n local constPregnancyCheck = {\n none = 0,\n pending = 1,\n success = 2,\n fail = 3,\n }\n\n local function judgePregnancyStart(nameText, riskText, checkDateText, pendingFlag)\n nameText = trim(tostring(nameText or \"\"))\n riskText = trim(tostring(riskText or \"\"))\n checkDateText = trim(tostring(checkDateText or \"\"))\n pendingFlag = trim(tostring(pendingFlag or \"\"))\n\n if nameText == \"\" then\n return constPregnancyCheck.none, 0, \"\"\n end\n\n if pendingFlag == constFlag.yes then\n return constPregnancyCheck.pending, 0, checkDateText\n end\n\n local risk = clamp(extractInt(riskText), 0, 100)\n\n if risk <= 0 or checkDateText == \"\" or not currentDateTable then\n return constPregnancyCheck.none, risk, checkDateText\n end\n\n local checkDateTable = parseDateText(checkDateText)\n if not checkDateTable then\n return constPregnancyCheck.none, risk, checkDateText\n end\n\n local diff = diffDaysBetweenDateTables(checkDateTable, currentDateTable)\n if diff == nil or diff <= 0 then\n return constPregnancyCheck.none, risk, checkDateText\n end\n\n if math.random(1, 100) <= risk then\n return constPregnancyCheck.success, risk, checkDateText\n end\n\n return constPregnancyCheck.fail, risk, checkDateText\n end\n\n local function addPregnancyCheckResult(result, nameText)\n if result == constPregnancyCheck.pending or result == constPregnancyCheck.success then\n table.insert(pregnancyStartList, nameText)\n end\n end\n\n -- ==========================================\n -- 히로인 캐릭터 전체\n -- hr_ConceptionRisk:\n -- index별 risk^checkDate^pending\n -- 예) |||100^20250605^1\n -- ==========================================\n do\n local oldRaw = safeGet(triggerId, \"hr_ConceptionRisk\", \"\")\n local riskList = splitLiteral(oldRaw, \"|\")\n local heroineCodeList = getHeroineCodeList(triggerId)\n local changed = false\n\n for i, code in ipairs(heroineCodeList) do\n local nameText = trim(getCharacterNameByCode(triggerId, code) or \"\")\n local record = trim(riskList[i] or \"\")\n\n if record ~= \"\" then\n local parts = splitLiteral(record, \"^\")\n local result, risk, dateText = judgePregnancyStart(nameText, parts[1] or \"\", parts[2] or \"\", parts[3] or \"\")\n\n if result == constPregnancyCheck.success then\n riskList[i] = tostring(roundHalfUp(risk)) .. \"^\" .. dateText .. \"^\" .. constFlag.yes\n changed = true\n\n elseif result == constPregnancyCheck.fail then\n riskList[i] = \"\"\n changed = true\n end\n\n addPregnancyCheckResult(result, nameText)\n end\n end\n\n if changed == true then\n local lastUsed = 0\n\n for i = 1, #riskList do\n if trim(riskList[i] or \"\") ~= \"\" then\n lastUsed = i\n end\n end\n\n local out = {}\n\n for i = 1, lastUsed do\n out[i] = tostring(riskList[i] or \"\")\n end\n\n setTrackedChatVar(triggerId, changedVars, \"hr_ConceptionRisk\", table.concat(out, \"|\"))\n end\n end\n\n setChatVar(triggerId, \"hr_List_PregnancyStart\", table.concat(pregnancyStartList, \", \"))\nend\n\n-- ==========================================\n-- 공용 파싱\n-- ==========================================\nfunction parseOrderedFieldsWithFallback(raw, orderedKeys)\n raw = trim(tostring(raw or \"\"))\n\n local parsed = {}\n local rawParts = {}\n\n for _, key in ipairs(orderedKeys or {}) do\n parsed[string.lower(trim(key or \"\"))] = \"\"\n end\n\n for part in raw:gmatch(\"([^|]+)\") do\n local token = trim(part)\n table.insert(rawParts, token)\n\n local key, value = token:match(\"^%s*(.-)%s*:%s*(.-)%s*$\")\n if key then\n local loweredKey = string.lower(trim(key))\n\n if parsed[loweredKey] ~= nil then\n parsed[loweredKey] = trim(value or \"\")\n end\n end\n end\n\n local function getKeyName(index)\n return string.lower(trim((orderedKeys or {})[index] or \"\"))\n end\n\n local allMissing = true\n for _, key in ipairs(orderedKeys or {}) do\n if parsed[string.lower(trim(key or \"\"))] ~= \"\" then\n allMissing = false\n break\n end\n end\n\n -- ==========================================\n -- 완전 비정상형:\n -- [값1 | 값2 | ...]\n -- orderedKeys 순서대로 그대로 매핑\n -- ==========================================\n if allMissing then\n if #rawParts >= 2 then\n for i, key in ipairs(orderedKeys or {}) do\n parsed[string.lower(trim(key or \"\"))] = trim(rawParts[i] or \"\")\n end\n return parsed\n end\n end\n\n -- ==========================================\n -- 반쯤 비정상형:\n -- 일부 키만 있거나 일부만 빠진 경우\n -- 해당 위치 토큰으로만 안전하게 보정\n -- ==========================================\n for i, key in ipairs(orderedKeys or {}) do\n local loweredKey = string.lower(trim(key or \"\"))\n\n if parsed[loweredKey] == \"\" then\n local token = trim(rawParts[i] or \"\")\n\n if token ~= \"\" then\n local tokenKey, tokenValue = token:match(\"^%s*(.-)%s*:%s*(.-)%s*$\")\n\n if not tokenKey then\n parsed[loweredKey] = token\n elseif string.lower(trim(tokenKey or \"\")) == loweredKey then\n parsed[loweredKey] = trim(tokenValue or \"\")\n end\n end\n end\n end\n\n return parsed\nend\n\nfunction normalizeIndexedSourceName(raw)\n raw = trim(tostring(raw or \"\"))\n if raw == \"\" then\n return \"\"\n end\n\n raw = trim(raw:gsub(\"^(.-)%s+[A-Z]$\", \"%1\"))\n raw = trim(raw:gsub(\"^(.-)%s+[A-Z](의%s*.+)$\", \"%1%2\"))\n raw = trim(raw:gsub(\"^(.-)%s+[A-Z]('s%s*.+)$\", \"%1%2\"))\n raw = trim(raw:gsub(\"^(.-)%s+[A-Z](の%s*.+)$\", \"%1%2\"))\n\n -- 1 / 2 / 3 / 4 ... 제거\n raw = trim(raw:gsub(\"^(.-)%s+%d+$\", \"%1\"))\n raw = trim(raw:gsub(\"^(.-)%s+%d+(의%s*.+)$\", \"%1%2\"))\n raw = trim(raw:gsub(\"^(.-)%s+%d+('s%s*.+)$\", \"%1%2\"))\n raw = trim(raw:gsub(\"^(.-)%s+%d+(の%s*.+)$\", \"%1%2\"))\n\n return raw\nend\n\nfunction isMeaningfulValue(raw)\n raw = trim(tostring(raw or \"\"))\n\n if raw == \"\" then\n return false\n end\n\n local key = raw:lower()\n key = key:gsub(\"%s+\", \"\")\n\n local meaningless = {\n [\"n/a\"] = true,\n [\"na\"] = true,\n [\"none\"] = true,\n [\"null\"] = true,\n [\"nil\"] = true,\n [\"없음\"] = true,\n [\"なし\"] = true,\n [\"無し\"] = true,\n [\"-\"] = true,\n }\n\n return meaningless[key] ~= true\nend\n\nfunction maskThoughtBlocksForParsing(text)\n text = tostring(text or \"\")\n\n local thoughtBlocks = {}\n local markerChar = string.char(30)\n\n local function makeKey()\n return markerChar .. \"HW_THOUGHT_BLOCK_\" .. tostring(#thoughtBlocks + 1) .. markerChar\n end\n\n local function storeBlock(block)\n local key = makeKey()\n\n table.insert(thoughtBlocks, {\n key = key,\n text = block,\n })\n\n return key\n end\n\n local maskedText = text\n\n -- 처음부터 맨 마지막 </Thoughts>까지 전부 Thoughts 잔해로 보고 마스킹\n local lastEnd = nil\n local searchStart = 1\n\n while true do\n local i, j = maskedText:find(\"<%s*/%s*Thoughts%s*>\", searchStart)\n\n if not i then\n break\n end\n\n lastEnd = j\n searchStart = j + 1\n end\n\n if lastEnd then\n local block = maskedText:sub(1, lastEnd)\n local rest = maskedText:sub(lastEnd + 1)\n\n maskedText = storeBlock(block) .. rest\n end\n\n return maskedText, thoughtBlocks\nend\n\nfunction restoreThoughtBlocksAfterParsing(text, thoughtBlocks)\n text = tostring(text or \"\")\n\n for _, item in ipairs(thoughtBlocks or {}) do\n -- replacement 문자열에 %가 들어가도 깨지지 않게 함수형 replacement 사용\n text = text:gsub(item.key, function()\n return item.text\n end)\n end\n\n return text\nend\n\nfunction sanitizeChoiceFieldValue(text)\n local s = trim(tostring(text or \"\"))\n\n s = s:gsub(\"[\\r\\n]+\", \" \")\n s = s:gsub(\"|\", \"/\")\n s = s:gsub(\"%[\", \"(\")\n s = s:gsub(\"%]\", \")\")\n\n return trim(s)\nend\n\nfunction stripOuterLineQuotes(text)\n local s = trim(tostring(text or \"\"))\n\n s = s:gsub('\"', \"\")\n s = s:gsub(\"'\", \"\")\n s = s:gsub(\"“\", \"\"):gsub(\"”\", \"\")\n s = s:gsub(\"‘\", \"\"):gsub(\"’\", \"\")\n\n return trim(s)\nend\n\nfunction collectBracketRecordsFromBody(body, fallbackMode)\n local records = {}\n\n body = tostring(body or \"\")\n\n for recordRaw in body:gmatch(\"%b[]\") do\n table.insert(records, recordRaw)\n end\n\n if #records <= 0 then\n if fallbackMode == \"lines\" then\n for line in body:gmatch(\"[^\\r\\n]+\") do\n line = trim(line)\n if line ~= \"\" then\n table.insert(records, line)\n end\n end\n else\n local raw = trim(body)\n if raw ~= \"\" then\n table.insert(records, raw)\n end\n end\n end\n\n return records\nend\n\nfunction normalizeRecordRaw(recordRaw)\n recordRaw = trim(tostring(recordRaw or \"\"))\n\n if recordRaw == \"\" then\n return \"\"\n end\n\n recordRaw = trim(recordRaw:gsub(\"^%[\", \"\"):gsub(\"%]$\", \"\"))\n\n return recordRaw\nend\n\nfunction stripNamedTagBlockAndNormalize(text, tagName)\n text = tostring(text or \"\")\n\n local escapedTagName = tostring(tagName or \"\"):gsub(\n \"([%^%$%(%)%%%.%[%]%*%+%-%?])\",\n \"%%%1\"\n )\n\n if escapedTagName == \"\" then\n return trim(text:gsub(\"\\n%s*\\n%s*\\n+\", \"\\n\\n\"))\n end\n\n text = text:gsub(\n \"<\" .. escapedTagName .. \"[^>]*>%s*[%s%S]-%s*</\" .. escapedTagName .. \"[^>]*>\",\n \"\"\n )\n\n text = text:gsub(\"\\n%s*\\n%s*\\n+\", \"\\n\\n\")\n\n return trim(text)\nend\n\nfunction getOrderEmoji(order)\n local map = {\n [1] = \"1️⃣\",\n [2] = \"2️⃣\",\n [3] = \"3️⃣\",\n }\n\n return map[tonumber(order) or 0] or \"❔\"\nend\n\n-- ==========================================\n-- 메모리\n-- ==========================================\nfunction appendMemoryEntry(triggerId, codeText, typeCodeText, dateText, milestoneText, contentText, extraText, changedVars)\n codeText = trim(tostring(codeText or \"\"))\n typeCodeText = trim(tostring(typeCodeText or \"\"))\n dateText = trim(tostring(dateText or \"\"))\n milestoneText = trim(tostring(milestoneText or \"\"))\n contentText = trim(tostring(contentText or \"\"))\n extraText = trim(tostring(extraText or \"\"))\n\n local isEmptyContentAllowed =\n typeCodeText == tostring(constMemoryType.sexualEffect_Add) or\n typeCodeText == tostring(constMemoryType.sexualEffect_Remove)\n\n if codeText == \"\" or typeCodeText == \"\" or dateText == \"\" then\n return\n end\n\n if isEmptyContentAllowed ~= true and contentText == \"\" then\n return\n end\n\n local function cleanField(text)\n text = trim(tostring(text or \"\"))\n text = text:gsub(\"\\r\\n\", \" \")\n text = text:gsub(\"\\r\", \" \")\n text = text:gsub(\"\\n\", \" \")\n text = text:gsub(\"|\", \"/\")\n text = text:gsub(\"§\", \"/\")\n text = text:gsub(\"%s+\", \" \")\n return trim(text)\n end\n\n codeText = cleanField(codeText)\n typeCodeText = cleanField(typeCodeText)\n dateText = cleanField(dateText)\n milestoneText = cleanField(milestoneText)\n contentText = cleanField(contentText)\n extraText = cleanField(extraText)\n\n local numKey = \"hr_Memory_Num\"\n local contentKey = \"hr_Memory_Content\"\n\n local oldNum = tonumber(safeGet(triggerId, numKey, \"0\")) or 0\n local oldContent = safeGet(triggerId, contentKey, \"\")\n\n local entryParts = {}\n\n entryParts[constMemoryField.heroineCode] = codeText\n entryParts[constMemoryField.typeCode] = typeCodeText\n entryParts[constMemoryField.date] = dateText\n entryParts[constMemoryField.milestone] = milestoneText\n entryParts[constMemoryField.content] = contentText\n entryParts[constMemoryField.extra] = extraText\n\n local entry = table.concat(entryParts, \"§\")\n\n setTrackedChatVar(triggerId, changedVars, numKey, tostring(oldNum + 1))\n setChatVar(triggerId, contentKey, appendSeparated(oldContent, entry, \"|\"))\nend\n\nfunction buildMemoryProfile(triggerId, targetCode, targetName)\n targetCode = trim(tostring(targetCode or \"\"))\n targetName = trim(tostring(targetName or \"\"))\n\n if targetCode == \"\" then\n return \"\"\n end\n\n if targetName == \"\" then\n targetName = \"History\"\n end\n\n local numKey = \"hr_Memory_Num\"\n local contentKey = \"hr_Memory_Content\"\n\n local memoryNum = tonumber(safeGet(triggerId, numKey, \"0\")) or 0\n local rawContent = safeGet(triggerId, contentKey, \"\")\n\n if memoryNum <= 0 or rawContent == \"\" then\n return \"\"\n end\n\n local records = splitLiteral(rawContent, \"|\")\n local memoryLines = {}\n\n local limit = #records\n if memoryNum > 0 and memoryNum < limit then\n limit = memoryNum\n end\n\n local eventSummaryTypeText = tostring(constMemoryType.eventSummary)\n local firstExperienceTypeText = tostring(constMemoryType.firstExperience)\n\n local sexualEffectAddTypeText = tostring(constMemoryType.sexualEffect_Add)\n local sexualEffectRemoveTypeText = tostring(constMemoryType.sexualEffect_Remove)\n\n local function isSexualEffectMemoryType(typeCodeText)\n return typeCodeText == sexualEffectAddTypeText\n or typeCodeText == sexualEffectRemoveTypeText\n end\n\n local function formatSingleDateDisplay(dateText)\n dateText = trim(tostring(dateText or \"\"))\n\n if dateText == \"\" then\n return \"\"\n end\n\n local startDateText, endDateText = dateText:match(\"^%s*(.-)%s*~%s*(.-)%s*$\")\n\n if startDateText and endDateText then\n local startDisplay = formatDateTextWithHyphen(startDateText)\n local endDisplay = formatDateTextWithHyphen(endDateText)\n\n if startDisplay == \"\" then\n return endDisplay\n end\n\n if endDisplay == \"\" then\n return startDisplay\n end\n\n if startDisplay == endDisplay then\n return startDisplay\n end\n\n return startDisplay .. \" ~ \" .. endDisplay\n end\n\n return formatDateTextWithHyphen(dateText)\n end\n\n local function formatDateRangeDisplay(startDateText, endDateText)\n local startDisplay = formatSingleDateDisplay(startDateText)\n local endDisplay = formatSingleDateDisplay(endDateText)\n\n if startDisplay == \"\" then\n return endDisplay\n end\n\n if endDisplay == \"\" then\n return startDisplay\n end\n\n if startDisplay == endDisplay then\n return startDisplay\n end\n\n return startDisplay .. \" ~ \" .. endDisplay\n end\n\n local function buildMemoryLineFromDateDisplay(dateDisplay, milestoneText, displayContentText)\n dateDisplay = trim(tostring(dateDisplay or \"\"))\n milestoneText = trim(tostring(milestoneText or \"\"))\n displayContentText = trim(tostring(displayContentText or \"\"))\n\n if dateDisplay == \"\" or displayContentText == \"\" then\n return \"\"\n end\n\n local lineText = \"- \" .. dateDisplay\n\n if milestoneText ~= \"\" then\n lineText = lineText .. \" \" .. milestoneText\n end\n\n lineText = lineText .. \": \" .. displayContentText\n\n return lineText\n end\n\n local function buildMemoryLine(dateText, milestoneText, displayContentText)\n return buildMemoryLineFromDateDisplay(\n formatSingleDateDisplay(dateText),\n milestoneText,\n displayContentText\n )\n end\n\n local eventSummaryTotal = 0\n\n for i = 1, limit do\n local record = trim(records[i] or \"\")\n\n if record ~= \"\" then\n local parts = splitLiteral(record, \"§\")\n\n local codeText = trim(parts[constMemoryField.heroineCode] or \"\")\n local typeCodeText = trim(parts[constMemoryField.typeCode] or \"\")\n local dateText = trim(parts[constMemoryField.date] or \"\")\n local contentText = trim(parts[constMemoryField.content] or \"\")\n\n if codeText == targetCode\n and typeCodeText == eventSummaryTypeText\n and dateText ~= \"\"\n and contentText ~= \"\" then\n\n eventSummaryTotal = eventSummaryTotal + 1\n end\n end\n end\n\n local sexualDisplayByIndex = {}\n local openSexualEffectByName = {}\n\n for i = 1, limit do\n local record = trim(records[i] or \"\")\n\n if record ~= \"\" then\n local parts = splitLiteral(record, \"§\")\n\n local codeText = trim(parts[constMemoryField.heroineCode] or \"\")\n local typeCodeText = trim(parts[constMemoryField.typeCode] or \"\")\n local dateText = trim(parts[constMemoryField.date] or \"\")\n local effectName = trim(parts[constMemoryField.milestone] or \"\")\n\n if codeText == targetCode\n and dateText ~= \"\"\n and effectName ~= \"\"\n and isSexualEffectMemoryType(typeCodeText) == true then\n\n if typeCodeText == sexualEffectAddTypeText then\n openSexualEffectByName[effectName] = {\n index = i,\n date = dateText,\n }\n\n sexualDisplayByIndex[i] = {\n dateDisplay = formatSingleDateDisplay(dateText),\n milestone = effectName,\n content = \"Acquired.\",\n }\n\n elseif typeCodeText == sexualEffectRemoveTypeText then\n local openInfo = openSexualEffectByName[effectName]\n\n if openInfo ~= nil then\n sexualDisplayByIndex[openInfo.index] = nil\n\n local dateDisplay = formatDateRangeDisplay(openInfo.date, dateText)\n local startDisplay = formatSingleDateDisplay(openInfo.date)\n local endDisplay = formatSingleDateDisplay(dateText)\n\n sexualDisplayByIndex[i] = {\n dateDisplay = dateDisplay,\n milestone = effectName,\n content = \"Acquired and ended.\",\n }\n\n openSexualEffectByName[effectName] = nil\n else\n sexualDisplayByIndex[i] = {\n dateDisplay = formatSingleDateDisplay(dateText),\n milestone = effectName,\n content = \"Ended.\",\n }\n end\n end\n end\n end\n end\n\n local eventSummarySeen = 0\n\n local firstExperienceDisplayGroupByKey = {}\n\n local function updateFirstExperienceDisplayGroup(group)\n if not group or not group.lineIndex then\n return\n end\n\n local milestoneText = table.concat(group.milestones, \", \")\n\n memoryLines[group.lineIndex] = buildMemoryLineFromDateDisplay(\n group.dateDisplay,\n milestoneText,\n group.content\n )\n end\n\n local function appendOrMergeFirstExperienceLine(dateText, milestoneText, displayContentText)\n local dateDisplay = formatSingleDateDisplay(dateText)\n milestoneText = trim(tostring(milestoneText or \"\"))\n displayContentText = trim(tostring(displayContentText or \"\"))\n\n if dateDisplay == \"\" or displayContentText == \"\" then\n return\n end\n\n -- milestone이 비어 있으면 일반 메모리처럼 1줄 추가\n if milestoneText == \"\" then\n local lineText = buildMemoryLineFromDateDisplay(\n dateDisplay,\n milestoneText,\n displayContentText\n )\n\n if lineText ~= \"\" then\n table.insert(memoryLines, lineText)\n end\n\n return\n end\n\n -- buildMemoryProfile 자체가 targetCode별로 돌기 때문에\n -- 여기서는 날짜 + 내용이 같으면 같은 캐릭터의 첫경험으로 취급해도 됨.\n local groupKey = dateDisplay .. \"\\x1F\" .. displayContentText\n local group = firstExperienceDisplayGroupByKey[groupKey]\n\n if group == nil then\n group = {\n lineIndex = #memoryLines + 1,\n dateDisplay = dateDisplay,\n content = displayContentText,\n milestones = {},\n milestoneMap = {},\n }\n\n firstExperienceDisplayGroupByKey[groupKey] = group\n table.insert(memoryLines, \"\")\n end\n\n if group.milestoneMap[milestoneText] ~= true then\n group.milestoneMap[milestoneText] = true\n table.insert(group.milestones, milestoneText)\n end\n\n updateFirstExperienceDisplayGroup(group)\n end\n\n for i = 1, limit do\n local record = trim(records[i] or \"\")\n\n if record ~= \"\" then\n local parts = splitLiteral(record, \"§\")\n\n local codeText = trim(parts[constMemoryField.heroineCode] or \"\")\n local typeCodeText = trim(parts[constMemoryField.typeCode] or \"\")\n local dateText = trim(parts[constMemoryField.date] or \"\")\n local milestoneText = trim(parts[constMemoryField.milestone] or \"\")\n local contentText = trim(parts[constMemoryField.content] or \"\")\n local extraText = trim(parts[constMemoryField.extra] or \"\")\n\n local matched = codeText == targetCode\n\n if matched == true then\n local sexualLine = sexualDisplayByIndex[i]\n\n if sexualLine ~= nil then\n local lineText = buildMemoryLineFromDateDisplay(\n sexualLine.dateDisplay,\n sexualLine.milestone,\n sexualLine.content\n )\n\n if lineText ~= \"\" then\n table.insert(memoryLines, lineText)\n end\n\n elseif isSexualEffectMemoryType(typeCodeText) == true then\n -- sexualEffect 계열은 위에서 Add/Remove 관계를 재구성해서 출력한다.\n -- 원본 메모리는 content가 비어 있으므로 여기서는 그대로 출력하지 않는다.\n\n elseif dateText ~= \"\" and contentText ~= \"\" then\n local displayContentText = contentText\n\n if typeCodeText == eventSummaryTypeText then\n eventSummarySeen = eventSummarySeen + 1\n\n local eventSummaryRecentIndex = eventSummaryTotal - eventSummarySeen + 1\n\n if eventSummaryRecentIndex > 5 and extraText ~= \"\" then\n displayContentText = extraText\n end\n end\n\n if typeCodeText == firstExperienceTypeText then\n appendOrMergeFirstExperienceLine(dateText, milestoneText, displayContentText)\n else\n local lineText = buildMemoryLine(dateText, milestoneText, displayContentText)\n\n if lineText ~= \"\" then\n table.insert(memoryLines, lineText)\n end\n end\n end\n end\n end\n end\n\n if #memoryLines <= 0 then\n return \"\"\n end\n\n local lines = {}\n\n table.insert(lines, \"### \" .. targetName)\n table.insert(lines, \"\")\n\n for _, line in ipairs(memoryLines) do\n table.insert(lines, line)\n end\n\n return table.concat(lines, \"\\n\")\nend\n\n-- ==========================================\n-- 파싱 상태창 히로인\n-- ==========================================\nfunction parseStatusWindowHeroineFilter(triggerId, parseMsg)\n parseMsg = tostring(parseMsg or \"\")\n\n if trim(safeGet(triggerId, \"hr_Option_StatusWindow\", constFlag.no)) ~= constFlag.yes then\n return parseMsg\n end\n\n local registeredHeroineNameMap = {}\n\n for _, code in ipairs(getHeroineCodeList(triggerId)) do\n local name = trim(getCharacterNameByCode(triggerId, code) or \"\")\n\n if name ~= \"\" then\n registeredHeroineNameMap[name] = true\n end\n end\n\n local function hasMeaningfulExtraValue(extraValue)\n extraValue = trim(tostring(extraValue or \"\"))\n\n if extraValue == \"\" then\n return false\n end\n\n -- Extra는 \"/\"로 복수 항목이 올 수 있으니, 하나라도 의미 있으면 유지\n for _, part in ipairs(splitLiteral(extraValue, \"/\")) do\n if isMeaningfulValue(part) == true then\n return true\n end\n end\n\n return false\n end\n\n local function normalizeStatusWindowDateLine(line)\n line = tostring(line or \"\")\n\n -- 예:\n -- [Date: 0450-12-06 | Time: ...]\n -- -> [Date: 450-12-06 | Time: ...]\n return line:gsub(\n \"^(%[Date:%s*)0*(%d+)(%-%d%d%-%d%d%s*|)\",\n function(prefix, year, rest)\n local yearNumber = tonumber(year)\n\n if yearNumber ~= nil then\n year = tostring(yearNumber)\n end\n\n return prefix .. year .. rest\n end,\n 1\n )\n end\n\n parseMsg = parseMsg:gsub(\n \"(<Scene Snapshot[^>]*>)([%s%S]-)(</Scene Snapshot>)\",\n function(openTag, body, closeTag)\n body = tostring(body or \"\")\n body = body:gsub(\"\\r\\n\", \"\\n\"):gsub(\"\\r\", \"\\n\")\n\n local outLines = {}\n\n for line in (body .. \"\\n\"):gmatch(\"(.-)\\n\") do\n local trimmedLine = trim(line)\n\n if trimmedLine ~= \"\" then\n trimmedLine = normalizeStatusWindowDateLine(trimmedLine)\n local extraValue = trimmedLine:match(\"^%[Extra:%s*(.-)%s*%]$\")\n\n if extraValue ~= nil then\n if hasMeaningfulExtraValue(extraValue) == true then\n table.insert(outLines, trimmedLine)\n end\n else\n local heroineName = trimmedLine:match(\"^%[Heroine:%s*([^|%]]+)\")\n\n if heroineName then\n heroineName = trim(heroineName)\n\n if registeredHeroineNameMap[heroineName] == true then\n table.insert(outLines, trimmedLine)\n end\n else\n table.insert(outLines, trimmedLine)\n end\n end\n end\n end\n\n return openTag ..\n \"\\n\" .. table.concat(outLines, \"\\n\") ..\n \"\\n\" .. closeTag\n end\n )\n\n parseMsg = parseMsg:gsub(\"\\n%s*\\n%s*\\n+\", \"\\n\\n\")\n\n return parseMsg\nend\n\n-- ==========================================\n-- 파싱 초기 설정\n-- ==========================================\nfunction parseHeroineInitialSettingBlocks(triggerId, cleanMsg)\n cleanMsg = tostring(cleanMsg or \"\")\n\n local allowedTargetMap = {}\n\n for _, targetName in ipairs(splitLiteral(safeGet(triggerId, \"hr_List_Init\", \"\"), \",\")) do\n targetName = trim(targetName or \"\")\n\n if targetName ~= \"\" then\n allowedTargetMap[targetName] = true\n end\n end\n\n local function normalizeKey(text)\n text = trim(tostring(text or \"\"))\n text = text:lower()\n text = text:gsub(\"%s+\", \" \")\n return trim(text)\n end\n\n local function archetypeToResponseValue(archetypeText)\n local map = {\n [\"pure\"] = constArousalResponse.pure,\n [\"cool\"] = constArousalResponse.cool,\n [\"tsundere\"] = constArousalResponse.tsundere,\n [\"tyrant\"] = constArousalResponse.tyrant,\n [\"bravado\"] = constArousalResponse.bravado,\n [\"timid\"] = constArousalResponse.timid,\n [\"denpa\"] = constArousalResponse.denpa,\n [\"broken\"] = constArousalResponse.broken,\n [\"hedonist\"] = constArousalResponse.hedonist,\n }\n\n return map[normalizeKey(archetypeText)] or constArousalResponse.none\n end\n\n local function buildIndexedListText(list)\n local lastUsed = 0\n\n for i = 1, #list do\n if trim(list[i] or \"\") ~= \"\" then\n lastUsed = i\n end\n end\n\n local out = {}\n\n for i = 1, lastUsed do\n out[i] = tostring(list[i] or \"\")\n end\n\n return table.concat(out, \"|\")\n end\n\n local function setArousalResponseSlotIfEmpty(index, value)\n index = tonumber(index) or 0\n value = tostring(value or \"\")\n\n if index < 1 or value == \"\" then\n return\n end\n\n local list = splitLiteral(safeGet(triggerId, \"hr_ArousalResponse\", \"\"), \"|\")\n\n for i = #list + 1, index do\n list[i] = \"\"\n end\n\n -- 이미 있으면 덮어쓰지 않음\n if trim(list[index] or \"\") ~= \"\" then\n return\n end\n\n list[index] = value\n\n setChatVar(triggerId, \"hr_ArousalResponse\", buildIndexedListText(list))\n end\n\n local appliedNameMap = {}\n\n local function applyInitialSettingRecord(recordRaw)\n recordRaw = trim(tostring(recordRaw or \"\"))\n\n if recordRaw == \"\" then\n return\n end\n\n recordRaw = trim(recordRaw:gsub(\"^%[\", \"\"):gsub(\"%]$\", \"\"))\n\n local fields = parseOrderedFieldsWithFallback(recordRaw, {\n \"Name\",\n \"Character Archetype\",\n })\n\n local nameText = trim(fields[\"name\"] or \"\")\n local archetypeText = trim(fields[\"character archetype\"] or \"\")\n\n if nameText == \"\" then\n return\n end\n\n -- 이번에 초기 설정하라고 요청한 대상만 반영\n if allowedTargetMap[nameText] ~= true then\n return\n end\n\n -- 같은 캐릭터 중복 기록되면 첫 번째만 사용\n if appliedNameMap[nameText] == true then\n return\n end\n\n local targetIndex = getHeroineIndexByName(triggerId, nameText)\n\n if targetIndex < 1 then\n return\n end\n\n local responseValue = archetypeToResponseValue(archetypeText)\n\n -- 없는 값이면 저장하지 않음. 그러면 다음번에도 hr_List_Init에 다시 잡힘.\n if responseValue == constArousalResponse.none then\n return\n end\n\n appliedNameMap[nameText] = true\n setArousalResponseSlotIfEmpty(targetIndex, responseValue)\n end\n\n for body in cleanMsg:gmatch(\"<Heroine Initial Setting[^>]*>%s*([%s%S]-)%s*</Heroine Initial Setting>\") do\n local hasBracketEntry = false\n\n for recordRaw in tostring(body):gmatch(\"%b[]\") do\n hasBracketEntry = true\n applyInitialSettingRecord(recordRaw)\n end\n\n if hasBracketEntry ~= true then\n for line in tostring(body):gmatch(\"[^\\r\\n]+\") do\n line = trim(line)\n\n if line ~= \"\" then\n applyInitialSettingRecord(line)\n end\n end\n end\n end\n\n cleanMsg = cleanMsg:gsub(\n \"<Heroine Initial Setting[^>]*>%s*[%s%S]-%s*</Heroine Initial Setting>\",\n \"\"\n )\n\n cleanMsg = cleanMsg:gsub(\"\\n%s*\\n%s*\\n+\", \"\\n\\n\")\n\n return cleanMsg\nend\n\n-- ==========================================\n-- 파싱 흥분 체크\n-- ==========================================\nfunction parsePhysiologicalArousalStateBlocks(triggerId, cleanMsg)\n cleanMsg = tostring(cleanMsg or \"\")\n\n local tempLogs = {}\n\n local registeredHeroineNameMap = {}\n\n for _, code in ipairs(getHeroineCodeList(triggerId)) do\n local name = trim(getCharacterNameByCode(triggerId, code) or \"\")\n\n if name ~= \"\" then\n registeredHeroineNameMap[name] = true\n end\n end\n\n local function getRecordName(recordRaw)\n recordRaw = trim(tostring(recordRaw or \"\"))\n\n if recordRaw == \"\" then\n return \"\"\n end\n\n recordRaw = trim(recordRaw:gsub(\"^%[\", \"\"):gsub(\"%]$\", \"\"))\n\n local fields = parseOrderedFieldsWithFallback(recordRaw, {\n \"Name\",\n \"Arousal\",\n \"Arousal Reception\",\n })\n\n return trim(fields[\"name\"] or \"\")\n end\n\n -- ==========================================\n -- 블록 정리\n -- 1) 등록된 히로인 캐릭터 이름이 아닌 기록 제거\n -- 2) 남은 기록이 없으면 블록 전체 제거\n -- ==========================================\n cleanMsg = cleanMsg:gsub(\n \"<Physiological Arousal State>%s*([%s%S]-)%s*</Physiological Arousal State>\",\n function(body)\n body = tostring(body or \"\")\n\n local records = {}\n local hasBracketEntry = false\n\n for recordRaw in body:gmatch(\"%b[]\") do\n hasBracketEntry = true\n\n local nameText = getRecordName(recordRaw)\n\n if nameText ~= \"\" and registeredHeroineNameMap[nameText] == true then\n table.insert(records, trim(recordRaw))\n end\n end\n\n if hasBracketEntry ~= true then\n local line = trim(body)\n\n if line ~= \"\" then\n local nameText = getRecordName(line)\n\n if nameText ~= \"\" and registeredHeroineNameMap[nameText] == true then\n line = trim(line:gsub(\"^%[\", \"\"):gsub(\"%]$\", \"\"))\n table.insert(records, \"[\" .. line .. \"]\")\n end\n end\n end\n\n if #records <= 0 then\n return \"\"\n end\n\n return \"<Physiological Arousal State>\\n\"\n .. table.concat(records, \"\\n\")\n .. \"\\n</Physiological Arousal State>\"\n end\n )\n\n cleanMsg = cleanMsg:gsub(\"\\n%s*\\n%s*\\n+\", \"\\n\\n\")\n\n local targetMap = {}\n\n for _, name in ipairs(splitLiteral(safeGet(triggerId, \"hr_List_Heroine\", \"\"), \",\")) do\n name = trim(tostring(name or \"\"))\n\n if name ~= \"\" then\n targetMap[name] = true\n end\n end\n\n local arousalPercentMap = {\n [\"minimal\"] = 10,\n [\"low\"] = 20,\n [\"subtle\"] = 30,\n [\"mild\"] = 40,\n [\"clear\"] = 50,\n [\"sustained\"] = 60,\n [\"strong\"] = 70,\n [\"very strong\"] = 80,\n [\"overwhelming\"] = 90,\n [\"peak\"] = 100,\n }\n\n local function normalizeArousalLevel(text)\n text = trim(tostring(text or \"\"))\n text = text:lower()\n text = text:gsub(\"%s+\", \" \")\n return trim(text)\n end\n\n local function addTempLog(text)\n text = trim(tostring(text or \"\"))\n\n if text ~= \"\" then\n table.insert(tempLogs, text)\n end\n end\n\n local appliedNameMap = {}\n\n local function tryParseRecord(recordRaw)\n recordRaw = trim(tostring(recordRaw or \"\"))\n\n if recordRaw == \"\" then\n return\n end\n\n recordRaw = trim(recordRaw:gsub(\"^%[\", \"\"):gsub(\"%]$\", \"\"))\n\n local fields = parseOrderedFieldsWithFallback(recordRaw, {\n \"Name\",\n \"Arousal\",\n \"Arousal Reception\",\n })\n\n local nameText = trim(fields[\"name\"] or \"\")\n local arousalText = trim(fields[\"arousal\"] or \"\")\n\n if nameText == \"\" then\n return\n end\n\n if targetMap[nameText] ~= true then\n return\n end\n\n -- 같은 캐릭터가 중복 기록되면 첫 번째만 사용\n if appliedNameMap[nameText] == true then\n return\n end\n\n local arousalKey = normalizeArousalLevel(arousalText)\n local percent = arousalPercentMap[arousalKey]\n\n if percent == nil then\n return\n end\n\n appliedNameMap[nameText] = true\n addTempLog(\"💗 \" .. nameText .. \" \" .. tostring(percent) .. \"%\")\n end\n\n for body in cleanMsg:gmatch(\"<Physiological Arousal State>%s*([%s%S]-)%s*</Physiological Arousal State>\") do\n local hasBracketEntry = false\n\n for recordRaw in tostring(body):gmatch(\"%b[]\") do\n hasBracketEntry = true\n tryParseRecord(recordRaw)\n end\n\n if hasBracketEntry ~= true then\n tryParseRecord(body)\n end\n end\n\n -- 중요: 유효한 블록은 삭제하지 않음.\n return cleanMsg, tempLogs\nend\n\n-- ==========================================\n-- 파싱 첫경험\n-- ==========================================\nfunction parseFirstExperienceCheckBlocks(triggerId, cleanMsg, changedVars)\n cleanMsg = tostring(cleanMsg or \"\")\n\n local allowedTargetMap = {}\n\n for _, targetName in ipairs(splitLiteral(safeGet(triggerId, \"hr_List_FirstCheck\", \"\"), \",\")) do\n targetName = trim(targetName or \"\")\n\n if targetName ~= \"\" then\n allowedTargetMap[targetName] = true\n end\n end\n\n local function buildIndexedListText(list)\n local lastUsed = 0\n\n for i = 1, #list do\n if trim(list[i] or \"\") ~= \"\" then\n lastUsed = i\n end\n end\n\n local out = {}\n\n for i = 1, lastUsed do\n out[i] = tostring(list[i] or \"\")\n end\n\n return table.concat(out, \"|\")\n end\n\n local function saveIndexedListUntracked(key, list)\n setChatVar(triggerId, key, buildIndexedListText(list))\n end\n\n local function setIndexedSlotIfEmptyUntracked(key, index, value)\n index = tonumber(index) or 0\n\n if index < 1 then\n return\n end\n\n local list = splitLiteral(safeGet(triggerId, key, \"\"), \"|\")\n\n for i = #list + 1, index do\n list[i] = \"\"\n end\n\n if trim(list[index] or \"\") ~= \"\" then\n return\n end\n\n list[index] = tostring(value or \"\")\n\n -- Content 계열은 SaveSessionData / changedVars에 넣지 않음\n saveIndexedListUntracked(key, list)\n end\n\n local function applyExperienceCheckRecord(recordRaw)\n recordRaw = trim(tostring(recordRaw or \"\"))\n\n if recordRaw == \"\" then\n return\n end\n\n recordRaw = trim(recordRaw:gsub(\"^%[\", \"\"):gsub(\"%]$\", \"\"))\n\n local fields = parseOrderedFieldsWithFallback(recordRaw, {\n \"Name\",\n \"Experiences\",\n })\n\n local nameText = trim(fields[\"name\"] or \"\")\n\n if nameText == \"\" then\n return\n end\n\n -- 이번에 체크하라고 요청한 대상만 반영\n if allowedTargetMap[nameText] ~= true then\n return\n end\n\n local targetIndex = getHeroineIndexByName(triggerId, nameText)\n\n if targetIndex < 1 then\n return\n end\n\n local hasKiss = false\n local hasVaginal = false\n local hasOral = false\n local hasAnal = false\n local hasNone = false\n\n local validCount = 0\n local experiencesText = tostring(fields[\"experiences\"] or \"\")\n\n for _, part in ipairs(splitLiteral(experiencesText, \",\")) do\n part = trim(part or \"\"):lower()\n part = part:gsub(\"%s+\", \" \")\n\n if part ~= \"\" then\n local matched = false\n\n if part:find(\"kiss\", 1, true) then\n hasKiss = true\n matched = true\n end\n\n if part:find(\"oral sex\", 1, true) then\n hasOral = true\n matched = true\n end\n\n if part:find(\"vaginal sex\", 1, true) then\n hasVaginal = true\n matched = true\n end\n\n if part:find(\"anal sex\", 1, true) then\n hasAnal = true\n matched = true\n end\n\n if part:find(\"none\", 1, true) then\n hasNone = true\n matched = true\n end\n\n if matched == true then\n validCount = validCount + 1\n end\n end\n end\n\n -- Kiss / Oral Sex / Vaginal Sex / Anal Sex / None 중 하나라도 유효해야 저장\n if validCount <= 0 then\n return\n end\n\n local flags =\n (hasKiss and constFlag.yes or constFlag.no) ..\n (hasOral and constFlag.yes or constFlag.no) ..\n (hasVaginal and constFlag.yes or constFlag.no) ..\n (hasAnal and constFlag.yes or constFlag.no)\n\n -- None만 있어도 0000으로 저장한다. 빈칸이 아니므로 첫 세팅 완료로 취급된다.\n setFirstExperienceSlot(triggerId, targetIndex, flags, changedVars)\n\n -- Content는 changedVars에 넣지 않고 setChatVar로만 저장.\n if hasKiss == true then\n setIndexedSlotIfEmptyUntracked(\"hr_FirstKiss_Content\", targetIndex, \"Pre-Story\")\n end\n\n if hasVaginal == true then\n setIndexedSlotIfEmptyUntracked(\"hr_FirstVaginal_Content\", targetIndex, \"Pre-Story\")\n end\n\n if hasOral == true then\n setIndexedSlotIfEmptyUntracked(\"hr_FirstOral_Content\", targetIndex, \"Pre-Story\")\n end\n\n if hasAnal == true then\n setIndexedSlotIfEmptyUntracked(\"hr_FirstAnal_Content\", targetIndex, \"Pre-Story\")\n end\n end\n\n for body in cleanMsg:gmatch(\"<Pre%-Story Sexual Experience[^>]*>%s*([%s%S]-)%s*</Pre%-Story Sexual Experience>\") do\n local hasBracketEntry = false\n\n for recordRaw in body:gmatch(\"%b[]\") do\n hasBracketEntry = true\n applyExperienceCheckRecord(recordRaw)\n end\n\n if hasBracketEntry ~= true then\n for line in tostring(body):gmatch(\"[^\\r\\n]+\") do\n line = trim(line)\n\n if line ~= \"\" then\n applyExperienceCheckRecord(line)\n end\n end\n end\n end\n\n cleanMsg = cleanMsg:gsub(\n \"<Pre%-Story Sexual Experience[^>]*>%s*[%s%S]-%s*</Pre%-Story Sexual Experience>\",\n \"\"\n )\n\n cleanMsg = cleanMsg:gsub(\"\\n%s*\\n%s*\\n+\", \"\\n\\n\")\n\n return cleanMsg\nend\n\nfunction parseFirstExperienceRecordBlocks(triggerId, cleanMsg, changedVars)\n cleanMsg = tostring(cleanMsg or \"\")\n\n local tempLogs = {}\n\n local defs = {\n {\n tag = \"Record First Kiss\",\n position = constFirstExperience.kiss,\n contentKey = \"hr_FirstKiss_Content\",\n milestone = \"First Kiss\",\n logLabel = \"첫 키스\",\n },\n {\n tag = \"Record First Oral\",\n position = constFirstExperience.oral,\n contentKey = \"hr_FirstOral_Content\",\n milestone = \"First Fellatio\",\n logLabel = \"첫 펠라치오\",\n },\n {\n tag = \"Record First Vaginal\",\n position = constFirstExperience.vaginal,\n contentKey = \"hr_FirstVaginal_Content\",\n milestone = \"Virginity Loss\",\n logLabel = \"처녀 상실\",\n },\n {\n tag = \"Record First Anal\",\n position = constFirstExperience.anal,\n contentKey = \"hr_FirstAnal_Content\",\n milestone = \"First Anal Sex\",\n logLabel = \"첫 항문 삽입\",\n },\n }\n\n local function cleanField(text)\n text = trim(tostring(text or \"\"))\n text = text:gsub(\"\\r\\n\", \" \")\n text = text:gsub(\"\\r\", \" \")\n text = text:gsub(\"\\n\", \" \")\n text = text:gsub(\"|\", \"/\")\n text = text:gsub(\"§\", \"/\")\n text = text:gsub(\"%s+\", \" \")\n return trim(text)\n end\n\n local function setIndexedSlot(key, index, value, useTracked)\n index = tonumber(index) or 0\n if index < 1 then\n return\n end\n\n local list = splitLiteral(safeGet(triggerId, key, \"\"), \"|\")\n\n for i = #list + 1, index do\n list[i] = \"\"\n end\n\n list[index] = tostring(value or \"\")\n\n local lastUsed = 0\n for i = 1, #list do\n if trim(list[i] or \"\") ~= \"\" then\n lastUsed = i\n end\n end\n\n local out = {}\n for i = 1, lastUsed do\n out[i] = tostring(list[i] or \"\")\n end\n\n local newValue = table.concat(out, \"|\")\n\n if useTracked == false then\n setChatVar(triggerId, key, newValue)\n else\n setTrackedChatVar(triggerId, changedVars, key, newValue)\n end\n end\n\n local function addTempLog(text)\n text = trim(tostring(text or \"\"))\n if text ~= \"\" then\n table.insert(tempLogs, text)\n end\n end\n\n local function trySaveRecord(def, recordRaw)\n recordRaw = trim(tostring(recordRaw or \"\"))\n if recordRaw == \"\" then\n return\n end\n\n recordRaw = trim(recordRaw:gsub(\"^%[\", \"\"):gsub(\"%]$\", \"\"))\n\n local fields = parseOrderedFieldsWithFallback(recordRaw, {\n \"Name\",\n \"Date\",\n \"Source\",\n \"Lead-up\",\n })\n\n local nameText = trim(fields[\"name\"] or \"\")\n local dateText = trim(fields[\"date\"] or \"\")\n local sourceText = normalizeIndexedSourceName(fields[\"source\"] or \"\")\n local leadUpText = trim(fields[\"lead-up\"] or \"\")\n\n if nameText == \"\" then\n return\n end\n\n if not parseDateText(dateText) then\n return\n end\n\n if not isMeaningfulValue(sourceText) then\n return\n end\n\n if not isMeaningfulValue(leadUpText) then\n return\n end\n\n local allowed = false\n for _, targetName in ipairs(splitLiteral(safeGet(triggerId, \"hr_List_FirstExperience\", \"\"), \",\")) do\n if trim(targetName or \"\") == nameText then\n allowed = true\n break\n end\n end\n\n if allowed ~= true then\n return\n end\n\n local targetCode = \"\"\n local targetIndex = 0\n\n -- 히로인 캐릭터\n if targetCode == \"\" then\n local heroineCodeList = getHeroineCodeList(triggerId)\n\n for i, code in ipairs(heroineCodeList) do\n local charName = trim(getCharacterNameByCode(triggerId, code) or \"\")\n\n if charName ~= \"\" and charName == nameText then\n targetCode = code\n targetIndex = i\n break\n end\n end\n end\n\n if targetCode == \"\" then\n return\n end\n\n sourceText = cleanField(sourceText)\n leadUpText = cleanField(leadUpText)\n\n local savedText = dateText .. \", \" .. sourceText\n\n if getFirstExperienceFlagByPosition(triggerId, targetIndex, def.position) == constFlag.yes then\n return\n end\n\n setFirstExperienceFlagByPosition(triggerId, targetIndex, def.position, constFlag.yes, changedVars)\n setIndexedSlot(def.contentKey, targetIndex, savedText, false)\n\n appendMemoryEntry(triggerId, targetCode, constMemoryType.firstExperience, dateText, def.milestone, leadUpText, \"\", changedVars)\n\n addTempLog(\"🥀 \" .. nameText .. \" \" .. def.logLabel .. \" (\" .. savedText .. \")\")\n end\n\n for _, def in ipairs(defs) do\n for body in cleanMsg:gmatch(\"<\" .. def.tag .. \">%s*([%s%S]-)%s*</\" .. def.tag .. \">\") do\n local hasBracketEntry = false\n\n for recordRaw in body:gmatch(\"%b[]\") do\n hasBracketEntry = true\n trySaveRecord(def, recordRaw)\n end\n\n if hasBracketEntry ~= true then\n trySaveRecord(def, body)\n end\n end\n end\n\n for _, def in ipairs(defs) do\n cleanMsg = cleanMsg:gsub(\"<\" .. def.tag .. \">%s*[%s%S]-%s*</\" .. def.tag .. \">\", \"\")\n end\n\n cleanMsg = cleanMsg:gsub(\"<Record First[^>]*>%s*[%s%S]-%s*</Record First[^>]*>\", \"\")\n cleanMsg = cleanMsg:gsub(\"\\n%s*\\n%s*\\n+\", \"\\n\\n\")\n\n return cleanMsg, tempLogs\nend\n\n-- ==========================================\n-- 파싱 질내사정\n-- ==========================================\nfunction parseConceptionRiskRecordBlocks(triggerId, cleanMsg, changedVars)\n cleanMsg = tostring(cleanMsg or \"\")\n\n local tempLogs = {}\n\n local recordedConceptionRiskCountByName = {}\n local recordedConceptionRiskNameOrder = {}\n\n local conceptionRiskStepMap = {\n [constCycle.fertile_m5] = 0,\n [constCycle.fertile_m4] = 1,\n [constCycle.fertile_m3] = 2,\n [constCycle.fertile_m2] = 3,\n [constCycle.fertile_p1] = 3,\n [constCycle.fertile_m1] = 4,\n [constCycle.ovulation] = 5,\n }\n\n local function getRiskPerCountByCycle(cycleFlag)\n cycleFlag = tonumber(cycleFlag) or 0\n\n local step = conceptionRiskStepMap[cycleFlag]\n\n if step == nil then\n return 0\n end\n\n -- step 0: 5~8\n -- step 1: 8~11\n -- step 2: 11~15\n -- step 3: 14~18\n -- step 4: 17~22\n -- step 5: 20~25\n local minRisk = roundHalfUp(5 + step * 3)\n local maxRisk = roundHalfUp(8 + step * 3.4)\n\n if maxRisk < minRisk then\n maxRisk = minRisk\n end\n\n return math.random(minRisk, maxRisk)\n end\n\n local function rollConceptionRiskByCount(cycleFlag, count)\n count = math.max(0, extractInt(count or \"0\"))\n\n if count <= 0 then\n return 0\n end\n\n local totalRisk = 0\n\n for i = 1, count do\n totalRisk = totalRisk + getRiskPerCountByCycle(cycleFlag)\n end\n\n return clamp(totalRisk, 0, 100)\n end\n\n local function addTempLog(text)\n text = trim(tostring(text or \"\"))\n\n if text ~= \"\" then\n table.insert(tempLogs, text)\n end\n end\n\n local function addRecordedConceptionRiskCount(nameText, count)\n nameText = trim(tostring(nameText or \"\"))\n count = math.max(0, extractInt(count or \"0\"))\n\n if nameText == \"\" or count <= 0 then\n return\n end\n\n nameText = nameText:gsub(\"[%[%]\\r\\n;]\", \" \")\n nameText = trim(nameText:gsub(\"%s+\", \" \"))\n\n if nameText == \"\" then\n return\n end\n\n if recordedConceptionRiskCountByName[nameText] == nil then\n recordedConceptionRiskCountByName[nameText] = 0\n table.insert(recordedConceptionRiskNameOrder, nameText)\n end\n\n recordedConceptionRiskCountByName[nameText] =\n recordedConceptionRiskCountByName[nameText] + count\n end\n\n local function setIndexedRisk(index, addRisk, dueDateText)\n index = tonumber(index) or 0\n addRisk = tonumber(addRisk) or 0\n dueDateText = trim(tostring(dueDateText or \"\"))\n\n if index < 1 or addRisk <= 0 then\n return 0, 0, 0\n end\n\n local oldRaw = safeGet(triggerId, \"hr_ConceptionRisk\", \"\")\n local list = splitLiteral(oldRaw, \"|\")\n\n for i = #list + 1, index do\n list[i] = \"\"\n end\n\n local oldParts = splitLiteral(list[index] or \"\", \"^\")\n local oldRisk = clamp(extractInt(oldParts[1] or \"0\"), 0, 100)\n local newRisk = clamp(oldRisk + addRisk, 0, 100)\n\n local newValue = tostring(roundHalfUp(newRisk))\n\n if dueDateText ~= \"\" then\n newValue = newValue .. \"^\" .. dueDateText\n end\n\n list[index] = newValue\n\n local lastUsed = 0\n for i = 1, #list do\n if trim(list[i] or \"\") ~= \"\" then\n lastUsed = i\n end\n end\n\n local out = {}\n for i = 1, lastUsed do\n out[i] = tostring(list[i] or \"\")\n end\n\n setTrackedChatVar(triggerId, changedVars, \"hr_ConceptionRisk\", table.concat(out, \"|\"))\n\n return oldRisk, newRisk, clamp(newRisk - oldRisk, 0, 100)\n end\n\n local function trySaveRecord(recordRaw)\n recordRaw = trim(tostring(recordRaw or \"\"))\n\n if recordRaw == \"\" then\n return\n end\n\n recordRaw = trim(recordRaw:gsub(\"^%[\", \"\"):gsub(\"%]$\", \"\"))\n\n local fields = parseOrderedFieldsWithFallback(recordRaw, {\n \"Name\",\n \"Count\",\n })\n\n local nameText = trim(fields[\"name\"] or \"\")\n local count = math.max(0, extractInt(fields[\"count\"] or \"0\"))\n\n if nameText == \"\" or count <= 0 then\n return\n end\n\n local allowedTarget = false\n if nameText ~= \"\" then\n for _, targetName in ipairs(splitLiteral(safeGet(triggerId, \"hr_List_ConceptionRisk\", \"\"), \",\")) do\n if trim(targetName or \"\") == nameText then\n allowedTarget = true\n break\n end\n end\n end\n\n if allowedTarget ~= true then\n return\n end\n\n local currentDateText = trim(safeGet(triggerId, \"hr_LastDate\", \"\"))\n local targetIndex = 0\n local targetKey = \"\"\n local lastPeriodText = \"\"\n\n local heroineCodeList = getHeroineCodeList(triggerId)\n\n for i, code in ipairs(heroineCodeList) do\n local charName = trim(getCharacterNameByCode(triggerId, code) or \"\")\n\n if charName ~= \"\" and charName == nameText then\n targetKey = code\n targetIndex = i\n break\n end\n end\n\n if targetIndex < 1 then\n return\n end\n\n local periodList = splitLiteral(safeGet(triggerId, \"hr_LastPeriod\", \"\"), \"|\")\n lastPeriodText = trim(periodList[targetIndex] or \"\")\n \n local cycleFlag = getCycleFlagByLastPeriod(triggerId, targetKey, currentDateText, lastPeriodText)\n\n if isConceptionRiskCycle(cycleFlag) ~= true then\n return\n end\n\n local addRisk = rollConceptionRiskByCount(cycleFlag, count)\n\n if addRisk <= 0 then\n return\n end\n\n local dueDateText = \"\"\n do\n local currentDateTable = parseDateText(currentDateText)\n local lastPeriodDateTable = parseDateText(lastPeriodText)\n\n if currentDateTable and lastPeriodDateTable then\n local elapsed = diffDaysBetweenDateTables(lastPeriodDateTable, currentDateTable)\n\n if elapsed ~= nil and elapsed >= 0 then\n local cycleDay = (elapsed % 28) + 1\n local dueDateTable = addDaysToDate(currentDateTable, 15 - cycleDay)\n\n if dueDateTable then\n dueDateText = formatDateText(dueDateTable)\n end\n end\n end\n end\n\n local oldRisk, newRisk, gainedRisk\n\n oldRisk, newRisk, gainedRisk = setIndexedRisk(targetIndex, addRisk, dueDateText)\n\n addRecordedConceptionRiskCount(nameText, count)\n\n if newRisk >= 100 and oldRisk >= 100 then\n addTempLog(\"🔺 \" .. nameText .. \" 임신 확률 100%\")\n elseif gainedRisk > 0 then\n addTempLog(\"🔺 \" .. nameText .. \" 임신 확률 \" .. tostring(roundHalfUp(newRisk)) .. \"% (+\" .. tostring(roundHalfUp(gainedRisk)) .. \"%)\")\n elseif newRisk > 0 then\n addTempLog(\"🔺 \" .. nameText .. \" 임신 확률 \" .. tostring(roundHalfUp(newRisk)) .. \"%\")\n end\n end\n\n for body in cleanMsg:gmatch(\"<Record Conception Risk>%s*([%s%S]-)%s*</Record Conception Risk>\") do\n local hasBracketEntry = false\n\n for recordRaw in body:gmatch(\"%b[]\") do\n hasBracketEntry = true\n trySaveRecord(recordRaw)\n end\n\n if hasBracketEntry ~= true then\n trySaveRecord(body)\n end\n end\n\n cleanMsg = cleanMsg:gsub(\"<Record Conception Risk>%s*[%s%S]-%s*</Record Conception Risk>\", \"\")\n cleanMsg = cleanMsg:gsub(\"\\n%s*\\n%s*\\n+\", \"\\n\\n\")\n\n local conceptionRiskRecordedText = \"\"\n\n if #recordedConceptionRiskNameOrder > 0 then\n local parts = {}\n\n table.insert(parts, \"<Conception Risk Recorded>\")\n\n for _, nameText in ipairs(recordedConceptionRiskNameOrder) do\n local recordedCount = tonumber(recordedConceptionRiskCountByName[nameText] or 0) or 0\n\n if recordedCount > 0 then\n table.insert(\n parts,\n \"[Name: \" .. nameText ..\n \" | Vaginal Ejaculation Count: \" .. tostring(recordedCount) ..\n \" | Already recorded; do not count again or add to risk.]\"\n )\n end\n end\n\n table.insert(parts, \"</Conception Risk Recorded>\")\n\n conceptionRiskRecordedText = table.concat(parts, \"\\n\")\n end\n\n return cleanMsg, tempLogs, conceptionRiskRecordedText\nend\n\n-- ==========================================\n-- 파싱 사후피임\n-- ==========================================\nfunction parseEmergencyContraceptionRecordBlocks(triggerId, cleanMsg, changedVars)\n cleanMsg = tostring(cleanMsg or \"\")\n\n local tempLogs = {}\n\n local contraceptiveEffectMap = {\n [\"very low\"] = 0.3,\n [\"low\"] = 0.4,\n [\"medium\"] = 0.5,\n [\"high\"] = 0.6,\n [\"very high\"] = 0.7,\n }\n\n local function addTempLog(text)\n text = trim(tostring(text or \"\"))\n\n if text ~= \"\" then\n table.insert(tempLogs, text)\n end\n end\n\n local function reduceIndexedRisk(index, code, effectRate)\n index = tonumber(index) or 0\n code = trim(tostring(code or \"\"))\n effectRate = tonumber(effectRate) or 0\n\n if index < 1 or code == \"\" or effectRate <= 0 then\n return 0, 0, 0\n end\n\n if getCharacterPregnancyRecordByCode(triggerId, code) ~= \"\" then\n return 0, 0, 0\n end\n\n local oldRaw = safeGet(triggerId, \"hr_ConceptionRisk\", \"\")\n local list = splitLiteral(oldRaw, \"|\")\n local parts = splitLiteral(list[index] or \"\", \"^\")\n\n local oldRisk = clamp(extractInt(parts[1] or \"0\"), 0, 100)\n local checkDateText = trim(parts[2] or \"\")\n local pendingFlag = trim(parts[3] or \"\")\n\n if oldRisk <= 0 or pendingFlag == constFlag.yes then\n return oldRisk, oldRisk, 0\n end\n\n local newRisk = clamp(oldRisk * (1 - effectRate), 0, 100)\n local roundedNewRisk = roundHalfUp(newRisk)\n local reducedRisk = clamp(oldRisk - roundedNewRisk, 0, 100)\n\n if roundedNewRisk <= 0 then\n list[index] = \"\"\n else\n list[index] = tostring(roundedNewRisk)\n\n if checkDateText ~= \"\" then\n list[index] = list[index] .. \"^\" .. checkDateText\n end\n end\n\n local lastUsed = 0\n\n for i = 1, #list do\n if trim(list[i] or \"\") ~= \"\" then\n lastUsed = i\n end\n end\n\n local out = {}\n\n for i = 1, lastUsed do\n out[i] = tostring(list[i] or \"\")\n end\n\n setTrackedChatVar(triggerId, changedVars, \"hr_ConceptionRisk\", table.concat(out, \"|\"))\n\n return oldRisk, roundedNewRisk, reducedRisk\n end\n\n local function trySaveRecord(recordRaw)\n recordRaw = trim(tostring(recordRaw or \"\"))\n\n if recordRaw == \"\" then\n return\n end\n\n recordRaw = trim(recordRaw:gsub(\"^%[\", \"\"):gsub(\"%]$\", \"\"))\n\n local fields = parseOrderedFieldsWithFallback(recordRaw, {\n \"Name\",\n \"Contraceptive Effect\",\n })\n\n local nameText = trim(fields[\"name\"] or \"\")\n local effectText = trim(fields[\"contraceptive effect\"] or \"\")\n local effectKey = trim(tostring(effectText or \"\")):lower()\n effectKey = effectKey:gsub(\"%s+\", \" \")\n\n local effectRate = contraceptiveEffectMap[effectKey] or 0\n\n if nameText == \"\" or effectRate <= 0 then\n return\n end\n\n local allowedTarget = false\n if nameText ~= \"\" then\n for _, targetName in ipairs(splitLiteral(safeGet(triggerId, \"hr_List_Contraception\", \"\"), \",\")) do\n if trim(targetName or \"\") == nameText then\n allowedTarget = true\n break\n end\n end\n end\n\n if allowedTarget ~= true then\n return\n end\n\n local targetIndex = 0\n local targetCode = \"\"\n\n \n local heroineCodeList = getHeroineCodeList(triggerId)\n\n for i, code in ipairs(heroineCodeList) do\n local charName = trim(getCharacterNameByCode(triggerId, code) or \"\")\n\n if charName ~= \"\" and charName == nameText then\n targetIndex = i\n targetCode = code\n break\n end\n end\n\n if targetIndex < 1 or targetCode == \"\" then\n return\n end\n \n\n local oldRisk, newRisk, reducedRisk\n\n oldRisk, newRisk, reducedRisk = reduceIndexedRisk(targetIndex, targetCode, effectRate)\n\n if reducedRisk > 0 then\n addTempLog(\"🔻 \" .. nameText .. \" 사후피임 \" .. tostring(roundHalfUp(newRisk)) .. \"% (-\" .. tostring(roundHalfUp(reducedRisk)) .. \"%)\")\n elseif oldRisk > 0 then\n addTempLog(\"🔻 \" .. nameText .. \" 사후피임 \" .. tostring(roundHalfUp(oldRisk)) .. \"%\")\n end\n end\n\n for body in cleanMsg:gmatch(\"<Emergency Contraception>%s*([%s%S]-)%s*</Emergency Contraception>\") do\n local hasBracketEntry = false\n\n for recordRaw in body:gmatch(\"%b[]\") do\n hasBracketEntry = true\n trySaveRecord(recordRaw)\n end\n\n if hasBracketEntry ~= true then\n trySaveRecord(body)\n end\n end\n\n cleanMsg = cleanMsg:gsub(\"<Emergency Contraception>%s*[%s%S]-%s*</Emergency Contraception>\", \"\")\n cleanMsg = cleanMsg:gsub(\"\\n%s*\\n%s*\\n+\", \"\\n\\n\")\n\n return cleanMsg, tempLogs\nend\n\n-- ==========================================\n-- 파싱 임신 확정\n-- ==========================================\nfunction parsePregnancyStartRecordBlocks(triggerId, cleanMsg, changedVars)\n cleanMsg = tostring(cleanMsg or \"\")\n\n local tempLogs = {}\n\n for body in cleanMsg:gmatch(\"<Pregnancy Start>%s*([%s%S]-)%s*</Pregnancy Start>\") do\n local recordList = {}\n\n for recordRaw in body:gmatch(\"%b[]\") do\n table.insert(recordList, recordRaw)\n end\n\n if #recordList <= 0 then\n table.insert(recordList, body)\n end\n\n for _, recordRaw in ipairs(recordList) do\n repeat\n recordRaw = trim(tostring(recordRaw or \"\"))\n if recordRaw == \"\" then break end\n\n recordRaw = trim(recordRaw:gsub(\"^%[\", \"\"):gsub(\"%]$\", \"\"))\n\n local fields = parseOrderedFieldsWithFallback(recordRaw, {\n \"Name\",\n \"Pregnancy Date\",\n \"Father\",\n \"Days Total\",\n \"Statement\",\n })\n\n local nameText = trim(fields[\"name\"] or \"\")\n local pregnancyDateText = trim(fields[\"pregnancy date\"] or \"\")\n local fatherText = normalizeIndexedSourceName(trim(fields[\"father\"] or \"\"))\n local daysTotal = extractInt(fields[\"days total\"] or \"\")\n local statementText = trim(fields[\"statement\"] or \"\")\n\n if nameText == \"\" or pregnancyDateText == \"\" or fatherText == \"\" or daysTotal <= 0 or statementText == \"\" then break end\n\n local pregnancyDateTable = parseDateText(pregnancyDateText)\n if not pregnancyDateTable then break end\n\n local saveDateText = formatDateText(pregnancyDateTable)\n local memoryDateText = pregnancyDateText\n\n fatherText = fatherText:gsub(\"\\r\\n\", \" \"):gsub(\"\\r\", \" \"):gsub(\"\\n\", \" \")\n fatherText = fatherText:gsub(\"|\", \"/\"):gsub(\"%^\", \"/\"):gsub(\"§\", \"/\"):gsub(\"=\", \"/\")\n fatherText = trim(fatherText:gsub(\"%s+\", \" \"))\n\n if fatherText == \"\" then break end\n\n local targetCode = \"\"\n local targetIndex = 0\n\n if targetCode == \"\" then\n local heroineCodeList = getHeroineCodeList(triggerId)\n local riskList = splitLiteral(safeGet(triggerId, \"hr_ConceptionRisk\", \"\"), \"|\")\n\n for i, code in ipairs(heroineCodeList) do\n local charName = trim(getCharacterNameByCode(triggerId, code) or \"\")\n\n if charName ~= \"\" and charName == nameText then\n targetCode = code\n targetIndex = i\n\n local parts = splitLiteral(riskList[i] or \"\", \"^\")\n local checkDateText = trim(parts[2] or \"\")\n local pendingFlag = trim(parts[3] or \"\")\n local checkDateTable = parseDateText(checkDateText)\n\n if pendingFlag == constFlag.yes and checkDateTable then\n saveDateText = formatDateText(checkDateTable)\n memoryDateText = formatDateTextWithHyphen(saveDateText)\n end\n\n break\n end\n end\n end\n\n if targetCode == \"\" or saveDateText == \"\" or memoryDateText == \"\" then break end\n\n local saved = false\n\n local oldRiskRaw = safeGet(triggerId, \"hr_ConceptionRisk\", \"\")\n local riskList = splitLiteral(oldRiskRaw, \"|\")\n\n if targetIndex >= 1 then\n riskList[targetIndex] = \"\"\n end\n\n local lastUsed = 0\n for i = 1, #riskList do\n if trim(riskList[i] or \"\") ~= \"\" then\n lastUsed = i\n end\n end\n\n local out = {}\n for i = 1, lastUsed do\n out[i] = tostring(riskList[i] or \"\")\n end\n\n local newRiskRaw = table.concat(out, \"|\")\n if newRiskRaw ~= oldRiskRaw then\n setTrackedChatVar(triggerId, changedVars, \"hr_ConceptionRisk\", newRiskRaw)\n end\n\n if getCharacterPregnancyRecordByCode(triggerId, targetCode) == \"\" then\n local oldPregnancyRaw = safeGet(triggerId, \"hr_Pregnancy\", \"\")\n local addValue = targetCode .. \"=\" .. saveDateText .. \"^\" .. fatherText .. \"^\" .. tostring(daysTotal)\n local newPregnancyRaw = appendSeparated(oldPregnancyRaw, addValue, \"|\")\n\n setTrackedChatVar(triggerId, changedVars, \"hr_Pregnancy\", newPregnancyRaw)\n saved = true\n end\n\n if saved == true then\n local lastPeriodList = splitLiteral(safeGet(triggerId, \"hr_LastPeriod\", \"\"), \"|\")\n\n for i = #lastPeriodList + 1, targetIndex do\n lastPeriodList[i] = \"\"\n end\n\n lastPeriodList[targetIndex] = \"X\"\n\n local lastUsed = 0\n for i = 1, #lastPeriodList do\n if trim(lastPeriodList[i] or \"\") ~= \"\" then\n lastUsed = i\n end\n end\n\n local out = {}\n for i = 1, lastUsed do\n out[i] = tostring(lastPeriodList[i] or \"\")\n end\n\n setTrackedChatVar(triggerId, changedVars, \"hr_LastPeriod\", table.concat(out, \"|\"))\n\n appendMemoryEntry(triggerId, targetCode, constMemoryType.pregnancyStart, memoryDateText, \"Pregnancy\", statementText, \"\", changedVars)\n\n table.insert(tempLogs, \"🤰 임신 \" .. memoryDateText .. \" \" .. nameText .. \" (\" .. fatherText .. \"의 아이)\")\n end\n until true\n end\n end\n\n cleanMsg = cleanMsg:gsub(\"<Pregnancy Start>%s*[%s%S]-%s*</Pregnancy Start>\", \"\")\n cleanMsg = cleanMsg:gsub(\"\\n%s*\\n%s*\\n+\", \"\\n\\n\")\n\n return cleanMsg, tempLogs\nend\n\n-- ==========================================\n-- 파싱 임신 종료\n-- ==========================================\nfunction parsePregnancyEndRecordBlocks(triggerId, cleanMsg, changedVars)\n cleanMsg = tostring(cleanMsg or \"\")\n\n local tempLogs = {}\n\n local validTypeMap = {\n Birth = true,\n Oviposition = true,\n Miscarriage = true,\n Abortion = true,\n }\n\n local typeLogMap = {\n Birth = \"👶 출산\",\n Oviposition = \"🥚 산란\",\n Miscarriage = \"🩸 유산\",\n Abortion = \"✂️ 낙태\",\n }\n\n local function addAllowedPregnancyEndName(allowedMap, name)\n name = trim(tostring(name or \"\"))\n\n if name ~= \"\" then\n allowedMap[name] = true\n end\n end\n\n local function buildPregnancyEndAllowedNameMap()\n local allowedMap = {}\n\n -- hr_List_PregnancyEnd는 \"A, B, C\" 형식\n for _, name in ipairs(splitLiteral(safeGet(triggerId, \"hr_List_PregnancyEnd\", \"\"), \",\")) do\n addAllowedPregnancyEndName(allowedMap, name)\n end\n\n -- hr_List_Birth는 \"이름: 날짜, father...\" 라인 형식\n local birthRaw = tostring(safeGet(triggerId, \"hr_List_Birth\", \"\") or \"\")\n\n for line in (birthRaw .. \"\\n\"):gmatch(\"([^\\n]*)\\n\") do\n line = line:gsub(\"\\r$\", \"\")\n\n local name = line:match(\"^%s*([^:]+)%s*:\")\n\n if name then\n addAllowedPregnancyEndName(allowedMap, name)\n end\n end\n\n return allowedMap\n end\n\n local pregnancyEndAllowedNameMap = buildPregnancyEndAllowedNameMap()\n\n for body in cleanMsg:gmatch(\"<Pregnancy End>%s*([%s%S]-)%s*</Pregnancy End>\") do\n local recordList = {}\n\n for recordRaw in body:gmatch(\"%b[]\") do\n table.insert(recordList, recordRaw)\n end\n\n if #recordList <= 0 then\n table.insert(recordList, body)\n end\n\n for _, recordRaw in ipairs(recordList) do\n repeat\n recordRaw = trim(tostring(recordRaw or \"\"))\n if recordRaw == \"\" then break end\n\n recordRaw = trim(recordRaw:gsub(\"^%[\", \"\"):gsub(\"%]$\", \"\"))\n\n local fields = parseOrderedFieldsWithFallback(recordRaw, {\n \"Name\",\n \"Pregnancy End Date\",\n \"Type\",\n \"Summary\",\n })\n\n local nameText = trim(fields[\"name\"] or \"\")\n local dateText = trim(fields[\"pregnancy end date\"] or \"\")\n local typeText = trim(fields[\"type\"] or \"\")\n local summaryText = trim(fields[\"summary\"] or \"\")\n\n if nameText == \"\" or dateText == \"\" or typeText == \"\" or summaryText == \"\" then break end\n if validTypeMap[typeText] ~= true then break end\n\n -- hr_List_PregnancyEnd 또는 hr_List_Birth에 있는 캐릭터만 저장\n if pregnancyEndAllowedNameMap[nameText] ~= true then break end\n\n summaryText = summaryText:gsub(\"\\r\\n\", \" \"):gsub(\"\\r\", \" \"):gsub(\"\\n\", \" \")\n summaryText = summaryText:gsub(\"|\", \"/\"):gsub(\"%^\", \"/\"):gsub(\"§\", \"/\"):gsub(\"=\", \"/\")\n summaryText = trim(summaryText:gsub(\"%s+\", \" \"))\n\n if summaryText == \"\" then break end\n\n local targetCode = \"\"\n local targetIndex = 0\n local fatherText = \"\"\n\n if targetCode == \"\" then\n local heroineCodeList = getHeroineCodeList(triggerId)\n\n for i, code in ipairs(heroineCodeList) do\n local charName = trim(getCharacterNameByCode(triggerId, code) or \"\")\n\n if charName ~= \"\" and charName == nameText then\n targetCode = code\n targetIndex = i\n\n local pregnancyRecord = getCharacterPregnancyRecordByCode(triggerId, code)\n local pregnancyParts = splitLiteral(pregnancyRecord, \"^\")\n fatherText = trim(pregnancyParts[2] or \"\")\n\n break\n end\n end\n end\n\n if targetCode == \"\" then break end\n\n local oldPregnancyRaw = safeGet(triggerId, \"hr_Pregnancy\", \"\")\n local pregnancyRecords = splitLiteral(oldPregnancyRaw, \"|\")\n local pregnancyOut = {}\n\n for _, record in ipairs(pregnancyRecords) do\n record = trim(record)\n\n if record ~= \"\" then\n local recordCode = trim(record:match(\"^%s*([^=]+)%s*=\") or \"\")\n\n if recordCode ~= targetCode then\n table.insert(pregnancyOut, record)\n end\n end\n end\n\n setTrackedChatVar(triggerId, changedVars, \"hr_Pregnancy\", table.concat(pregnancyOut, \"|\"))\n\n if targetIndex >= 1 then\n local oldLastPeriodRaw = safeGet(triggerId, \"hr_LastPeriod\", \"\")\n local lastPeriodList = splitLiteral(oldLastPeriodRaw, \"|\")\n\n for i = #lastPeriodList + 1, targetIndex do\n lastPeriodList[i] = \"\"\n end\n\n lastPeriodList[targetIndex] = \"\"\n\n local lastUsed = 0\n for i = 1, #lastPeriodList do\n if trim(lastPeriodList[i] or \"\") ~= \"\" then\n lastUsed = i\n end\n end\n\n local lastPeriodOut = {}\n for i = 1, lastUsed do\n lastPeriodOut[i] = tostring(lastPeriodList[i] or \"\")\n end\n\n setTrackedChatVar(triggerId, changedVars, \"hr_LastPeriod\", table.concat(lastPeriodOut, \"|\"))\n end\n\n appendMemoryEntry(triggerId, targetCode, constMemoryType.pregnancyEnd, dateText, typeText, summaryText, \"\", changedVars)\n\n local suffix = \"\"\n\n if fatherText ~= \"\" then\n if typeText == \"Oviposition\" then\n suffix = \" (\" .. fatherText .. \"의 알)\"\n else\n suffix = \" (\" .. fatherText .. \"의 아이)\"\n end\n end\n\n table.insert(tempLogs, typeLogMap[typeText] .. \" (\" .. dateText .. \") \" .. nameText .. suffix)\n until true\n end\n end\n\n cleanMsg = cleanMsg:gsub(\"<Pregnancy End>%s*[%s%S]-%s*</Pregnancy End>\", \"\")\n cleanMsg = cleanMsg:gsub(\"\\n%s*\\n%s*\\n+\", \"\\n\\n\")\n\n return cleanMsg, tempLogs\nend\n\n-- ==========================================\n-- 파싱 성적 특성\n--\n-- 입력 형식:\n-- <Sexual Trait Development>\n-- [Heroine Name: 이름 | Developed Trait: 새 특성 | Previous Trait: None 또는 기존 특성 | Icon: 이모지]\n-- </Sexual Trait Development>\n--\n-- hr_SexualTrait_Code:\n-- 히로인 순서별 슬롯은 | 로 구분하고, 각 히로인의 특성 번호는 ^ 로 구분한다.\n-- 예) 1^3|2\n--\n-- hr_SexualTrait_Content:\n-- 특성 이름은 항상 뒤에 추가만 한다.\n-- buildFullSave / SaveSessionData / changedVars에는 넣지 않는다.\n-- ==========================================\nfunction parseSexualTraitDevelopmentBlocks(triggerId, cleanMsg, changedVars)\n cleanMsg = tostring(cleanMsg or \"\")\n\n local MAX_SEXUAL_TRAITS_PER_HEROINE = 5\n\n local tempLogs = {}\n local codeKey = \"hr_SexualTrait_Code\"\n local contentKey = \"hr_SexualTrait_Content\"\n\n local function cleanTraitField(text)\n text = trim(tostring(text or \"\"))\n text = text:gsub(\"\\r\\n\", \" \")\n text = text:gsub(\"\\r\", \" \")\n text = text:gsub(\"\\n\", \" \")\n text = text:gsub(\"|\", \"/\")\n text = text:gsub(\"%^\", \"/\")\n text = text:gsub(\"§\", \"/\")\n text = text:gsub(\"%[\", \"(\")\n text = text:gsub(\"%]\", \")\")\n text = text:gsub(\"%s+\", \" \")\n return trim(text)\n end\n\n local function cleanIcon(text)\n text = trim(tostring(text or \"\"))\n text = text:gsub(\"\\r\\n\", \" \")\n text = text:gsub(\"\\r\", \" \")\n text = text:gsub(\"\\n\", \" \")\n text = text:gsub(\"|\", \"\")\n text = text:gsub(\"%^\", \"\")\n text = text:gsub(\"§\", \"\")\n text = text:gsub(\"%[\", \"\")\n text = text:gsub(\"%]\", \"\")\n text = text:gsub(\"%s+\", \" \")\n return trim(text)\n end\n\n local function normalizeTraitKey(text)\n text = trim(tostring(text or \"\"))\n text = text:gsub(\"^[<%[]\", \"\")\n text = text:gsub(\"[>%]]$\", \"\")\n text = text:gsub(\"%s+\", \" \")\n return string.lower(trim(text))\n end\n\n local function isNoneTrait(text)\n local key = normalizeTraitKey(text)\n\n return key == \"\"\n or key == \"none\"\n or key == \"nil\"\n or key == \"null\"\n or key == \"n/a\"\n or key == \"na\"\n or key == \"-\"\n or key == \"none or previous trait\"\n end\n\n local function isSameTrait(a, b)\n local ak = normalizeTraitKey(a)\n local bk = normalizeTraitKey(b)\n\n return ak ~= \"\" and bk ~= \"\" and ak == bk\n end\n\n local function addTempLog(text)\n text = trim(tostring(text or \"\"))\n if text ~= \"\" then\n table.insert(tempLogs, text)\n end\n end\n\n local function buildCodeText(slotList)\n local lastUsed = 0\n\n for i = 1, #slotList do\n if trim(slotList[i] or \"\") ~= \"\" then\n lastUsed = i\n end\n end\n\n local out = {}\n\n for i = 1, lastUsed do\n out[i] = tostring(slotList[i] or \"\")\n end\n\n return table.concat(out, \"|\")\n end\n\n local function appendTraitContentUntracked(traitName)\n traitName = cleanTraitField(traitName)\n\n if traitName == \"\" then\n return 0\n end\n\n local list = splitLiteral(safeGet(triggerId, contentKey, \"\"), \"|\")\n local lastUsed = 0\n\n for i = 1, #list do\n if trim(list[i] or \"\") ~= \"\" then\n lastUsed = i\n end\n end\n\n local newIndex = lastUsed + 1\n list[newIndex] = traitName\n\n local out = {}\n for i = 1, newIndex do\n out[i] = tostring(list[i] or \"\")\n end\n\n -- 중요:\n -- Content는 buildFullSave / SaveSessionData / changedVars에 넣지 않는다.\n setChatVar(triggerId, contentKey, table.concat(out, \"|\"))\n\n return newIndex\n end\n\n local function findPreviousTraitPosition(numList, contentList, previousTrait)\n previousTrait = trim(tostring(previousTrait or \"\"))\n\n if previousTrait == \"\" then\n return 0, \"\"\n end\n\n for i, numRaw in ipairs(numList) do\n local numText = trim(numRaw or \"\")\n local num = tonumber(numText) or 0\n local traitName = trim(contentList[num] or \"\")\n\n if num > 0 and (\n isSameTrait(traitName, previousTrait)\n or numText == previousTrait\n ) then\n if traitName ~= \"\" then\n return i, traitName\n end\n\n return i, previousTrait\n end\n end\n\n return 0, \"\"\n end\n\n local function normalizeNumList(slotText)\n local out = {}\n\n for _, numRaw in ipairs(splitLiteral(slotText or \"\", \"^\")) do\n local num = tonumber(trim(numRaw or \"\")) or 0\n\n if num > 0 then\n table.insert(out, tostring(num))\n end\n end\n\n return out\n end\n\n local function saveRecord(recordRaw)\n recordRaw = trim(tostring(recordRaw or \"\"))\n if recordRaw == \"\" then\n return\n end\n\n recordRaw = trim(recordRaw:gsub(\"^%[\", \"\"):gsub(\"%]$\", \"\"))\n\n local fields = parseOrderedFieldsWithFallback(recordRaw, {\n \"Heroine Name\",\n \"Developed Trait\",\n \"Previous Trait\",\n \"Icon\",\n })\n\n local nameText = trim(fields[\"heroine name\"] or \"\")\n local developedTrait = cleanTraitField(fields[\"developed trait\"] or \"\")\n local previousTrait = cleanTraitField(fields[\"previous trait\"] or \"\")\n local iconText = cleanIcon(fields[\"icon\"] or \"\")\n\n if iconText == \"\" then\n iconText = \"✨\"\n end\n\n if nameText == \"\" or developedTrait == \"\" then\n return\n end\n\n if isMeaningfulValue(developedTrait) ~= true then\n return\n end\n\n local targetIndex = getHeroineIndexByName(triggerId, nameText)\n\n -- 히로인 목록에 없으면 무시한다.\n -- 단, 블록 자체는 아래 gsub에서 제거된다.\n if targetIndex < 1 then\n return\n end\n\n local codeSlotList = splitLiteral(safeGet(triggerId, codeKey, \"\"), \"|\")\n\n for i = #codeSlotList + 1, targetIndex do\n codeSlotList[i] = \"\"\n end\n\n local currentNums = normalizeNumList(codeSlotList[targetIndex] or \"\")\n local oldContentList = splitLiteral(safeGet(triggerId, contentKey, \"\"), \"|\")\n\n local previousFound = false\n local previousPosition = 0\n local previousTraitName = \"\"\n\n if isNoneTrait(previousTrait) ~= true then\n previousPosition, previousTraitName = findPreviousTraitPosition(currentNums, oldContentList, previousTrait)\n previousFound = previousPosition > 0\n end\n\n -- Previous Trait가 없고 새로 획득하는 형태인데,\n -- Developed Trait가 이미 현재 보유 특성과 같으면 저장도 로그도 남기지 않는다.\n if isNoneTrait(previousTrait) == true then\n local alreadyOwnedPosition = findPreviousTraitPosition(currentNums, oldContentList, developedTrait)\n\n if alreadyOwnedPosition > 0 then\n return\n end\n end\n\n -- 새 특성 추가인데 이미 5개면 저장하지 않는다.\n -- 단, Previous Trait가 실제 기존 특성과 매칭된 \"변화/업그레이드\"는 허용한다.\n if previousFound ~= true and #currentNums >= MAX_SEXUAL_TRAITS_PER_HEROINE then\n return\n end\n\n -- Content에는 새 Trait를 뒤에 추가한다.\n local newTraitIndex = appendTraitContentUntracked(developedTrait)\n\n if newTraitIndex < 1 then\n return\n end\n\n if previousFound == true then\n currentNums[previousPosition] = tostring(newTraitIndex)\n\n if previousTraitName == \"\" then\n previousTraitName = previousTrait\n end\n\n addTempLog(iconText .. \" \" .. nameText .. \" 성적 특성 변화 \" .. previousTraitName .. \" → \" .. developedTrait)\n else\n table.insert(currentNums, tostring(newTraitIndex))\n addTempLog(iconText .. \" \" .. nameText .. \" 성적 특성 획득 \" .. developedTrait)\n end\n\n codeSlotList[targetIndex] = table.concat(currentNums, \"^\")\n\n -- Code만 changedVars / SaveSessionData 대상.\n -- Content는 위에서 setChatVar만 하고 changedVars에는 넣지 않음.\n setTrackedChatVar(triggerId, changedVars, codeKey, buildCodeText(codeSlotList))\n end\n\n for body in cleanMsg:gmatch(\"<Sexual Trait Development[^>]*>%s*([%s%S]-)%s*</Sexual Trait Development>\") do\n local hasBracketEntry = false\n\n for recordRaw in body:gmatch(\"%b[]\") do\n hasBracketEntry = true\n saveRecord(recordRaw)\n end\n\n if hasBracketEntry ~= true then\n for line in tostring(body):gmatch(\"[^\\r\\n]+\") do\n line = trim(line)\n\n if line ~= \"\" then\n saveRecord(line)\n end\n end\n end\n end\n\n cleanMsg = cleanMsg:gsub(\n \"<Sexual Trait Development[^>]*>%s*[%s%S]-%s*</Sexual Trait Development>\",\n \"\"\n )\n\n cleanMsg = cleanMsg:gsub(\"\\n%s*\\n%s*\\n+\", \"\\n\\n\")\n\n return cleanMsg, tempLogs\nend\n\n-- ==========================================\n-- 파싱 지속 효과\n-- ==========================================\nfunction parseImposedSexualEffectBlocks(triggerId, cleanMsg, changedVars)\n cleanMsg = tostring(cleanMsg or \"\")\n\n local tempLogs = {}\n local codeKey = \"hr_SexualEffect_Code\"\n local contentKey = \"hr_SexualEffect_Content\"\n\n local allowedEffectTypes = {\n [\"Hypnosis\"] = true,\n [\"Womb Crest\"] = true,\n [\"Tattoo\"] = true,\n [\"Covenant\"] = true,\n }\n\n local function cleanEffectField(text)\n text = trim(tostring(text or \"\"))\n text = text:gsub(\"\\r\\n\", \" \")\n text = text:gsub(\"\\r\", \" \")\n text = text:gsub(\"\\n\", \" \")\n text = text:gsub(\"|\", \"/\")\n text = text:gsub(\"%^\", \"/\")\n text = text:gsub(\"§\", \"/\")\n text = text:gsub(\"%[\", \"(\")\n text = text:gsub(\"%]\", \")\")\n text = text:gsub(\"%s+\", \" \")\n return trim(text)\n end\n\n local function cleanIcon(text)\n text = trim(tostring(text or \"\"))\n text = text:gsub(\"\\r\\n\", \" \")\n text = text:gsub(\"\\r\", \" \")\n text = text:gsub(\"\\n\", \" \")\n text = text:gsub(\"|\", \"\")\n text = text:gsub(\"%^\", \"\")\n text = text:gsub(\"§\", \"\")\n text = text:gsub(\"%[\", \"\")\n text = text:gsub(\"%]\", \"\")\n text = text:gsub(\"%s+\", \" \")\n return trim(text)\n end\n\n local function addTempLog(text)\n text = trim(tostring(text or \"\"))\n\n if text ~= \"\" then\n table.insert(tempLogs, text)\n end\n end\n\n local function isUnchangedValue(text)\n return trim(tostring(text or \"\")):lower() == \"unchanged\"\n end\n\n local function isApplicableHeroineName(nameText)\n nameText = trim(tostring(nameText or \"\"))\n\n if nameText == \"\" then\n return false\n end\n\n local listText = tostring(safeGet(triggerId, \"hr_List_Heroine\", \"\") or \"\")\n\n if trim(listText) == \"\" then\n return getHeroineIndexByName(triggerId, nameText) > 0\n end\n\n for line in listText:gmatch(\"[^\\r\\n,|]+\") do\n local candidate = trim(line or \"\")\n\n candidate = candidate:gsub(\"^%-+%s*\", \"\")\n candidate = candidate:gsub(\"^%d+[%.)]%s*\", \"\")\n candidate = trim(candidate)\n\n if candidate == nameText then\n return true\n end\n end\n\n return false\n end\n\n local function buildCodeText(slotList)\n local lastUsed = 0\n\n for i = 1, #slotList do\n if trim(slotList[i] or \"\") ~= \"\" then\n lastUsed = i\n end\n end\n\n local out = {}\n\n for i = 1, lastUsed do\n out[i] = tostring(slotList[i] or \"\")\n end\n\n return table.concat(out, \"|\")\n end\n\n local function normalizeNumList(slotText)\n local out = {}\n\n for _, numRaw in ipairs(splitLiteral(slotText or \"\", \"^\")) do\n local num = tonumber(trim(numRaw or \"\")) or 0\n\n if num > 0 then\n table.insert(out, tostring(num))\n end\n end\n\n return out\n end\n\n local function readEffectContent(effectIndex)\n effectIndex = tonumber(effectIndex) or 0\n\n if effectIndex < 1 then\n return \"\", \"\", \"\"\n end\n\n local list = splitLiteral(safeGet(triggerId, contentKey, \"\"), \"|\")\n local contentText = trim(list[effectIndex] or \"\")\n\n if contentText == \"\" then\n return \"\", \"\", \"\"\n end\n\n local parts = splitLiteral(contentText, \"^\")\n\n local effectName = cleanEffectField(parts[1] or \"\")\n local effectType = cleanEffectField(parts[2] or \"\")\n local effectDesc = cleanEffectField(parts[3] or \"\")\n\n return effectName, effectType, effectDesc\n end\n\n local function findEffectByName(currentNums, targetName)\n targetName = cleanEffectField(targetName)\n\n if targetName == \"\" then\n return 0, 0, \"\", \"\", \"\"\n end\n\n for slotPos, effectIndexText in ipairs(currentNums) do\n local effectIndex = tonumber(effectIndexText) or 0\n local effectName, effectType, effectDesc = readEffectContent(effectIndex)\n\n if effectName == targetName then\n return slotPos, effectIndex, effectName, effectType, effectDesc\n end\n end\n\n return 0, 0, \"\", \"\", \"\"\n end\n\n local function appendEffectContentUntracked(effectContent)\n effectContent = trim(tostring(effectContent or \"\"))\n\n if effectContent == \"\" then\n return 0\n end\n\n local list = splitLiteral(safeGet(triggerId, contentKey, \"\"), \"|\")\n local lastUsed = 0\n\n for i = 1, #list do\n if trim(list[i] or \"\") ~= \"\" then\n lastUsed = i\n end\n end\n\n local newIndex = lastUsed + 1\n list[newIndex] = effectContent\n\n local out = {}\n\n for i = 1, newIndex do\n out[i] = tostring(list[i] or \"\")\n end\n\n setChatVar(triggerId, contentKey, table.concat(out, \"|\"))\n\n return newIndex\n end\n\n local function getCodeSlotState(targetIndex)\n local codeSlotList = splitLiteral(safeGet(triggerId, codeKey, \"\"), \"|\")\n\n for i = #codeSlotList + 1, targetIndex do\n codeSlotList[i] = \"\"\n end\n\n local currentNums = normalizeNumList(codeSlotList[targetIndex] or \"\")\n\n return codeSlotList, currentNums\n end\n\n local function saveCodeSlotState(targetIndex, codeSlotList, currentNums)\n codeSlotList[targetIndex] = table.concat(currentNums, \"^\")\n setTrackedChatVar(triggerId, changedVars, codeKey, buildCodeText(codeSlotList))\n end\n\n local function appendSexualEffectMemory(nameText, memoryType, effectName)\n local targetCode = getCharacterCodeByName(triggerId, nameText)\n local memoryDateText = formatDateTextWithHyphen(safeGet(triggerId, \"hr_LastDate\", \"\"))\n\n if targetCode ~= \"\" and memoryDateText ~= \"\" and effectName ~= \"\" then\n appendMemoryEntry(\n triggerId,\n targetCode,\n memoryType,\n memoryDateText,\n effectName,\n \"\",\n \"\",\n changedVars\n )\n end\n end\n\n local function saveAdd(fields)\n local nameText = trim(fields[\"heroine name\"] or \"\")\n local iconText = cleanIcon(fields[\"icon\"] or \"\")\n local effectName = cleanEffectField(fields[\"name\"] or \"\")\n local effectType = cleanEffectField(fields[\"type\"] or \"\")\n local effectDesc = cleanEffectField(fields[\"effect\"] or \"\")\n\n if iconText == \"\" then\n iconText = \"✨\"\n end\n\n if isApplicableHeroineName(nameText) ~= true then\n return false\n end\n\n if nameText == \"\" or effectName == \"\" or effectType == \"\" or effectDesc == \"\" then\n return false\n end\n\n if isMeaningfulValue(effectName) ~= true then\n return false\n end\n\n if allowedEffectTypes[effectType] ~= true then\n return false\n end\n\n local targetIndex = getHeroineIndexByName(triggerId, nameText)\n\n if targetIndex < 1 then\n return false\n end\n\n local codeSlotList, currentNums = getCodeSlotState(targetIndex)\n\n local existingSlotPos = findEffectByName(currentNums, effectName)\n\n -- 이미 같은 Name의 효과가 있으면 저장하지 않고 해당 줄 제거.\n if existingSlotPos > 0 then\n return false\n end\n\n local contentText = effectName .. \"^\" .. effectType .. \"^\" .. effectDesc\n local newEffectIndex = appendEffectContentUntracked(contentText)\n\n if newEffectIndex < 1 then\n return false\n end\n\n table.insert(currentNums, tostring(newEffectIndex))\n saveCodeSlotState(targetIndex, codeSlotList, currentNums)\n\n appendSexualEffectMemory(nameText, constMemoryType.sexualEffect_Add, effectName)\n\n addTempLog(iconText .. \" \" .. nameText .. \" 지속 효과 추가 (\" .. effectType .. \") \" .. effectName)\n\n return true\n end\n\n local function saveUpdate(fields)\n local nameText = trim(fields[\"heroine name\"] or \"\")\n local iconText = cleanIcon(fields[\"icon\"] or \"\")\n local targetName = cleanEffectField(fields[\"target name\"] or \"\")\n local newNameRaw = cleanEffectField(fields[\"name\"] or \"\")\n local newEffectRaw = cleanEffectField(fields[\"effect\"] or \"\")\n\n if iconText == \"\" then\n iconText = \"✨\"\n end\n\n if isApplicableHeroineName(nameText) ~= true then\n return false\n end\n\n if nameText == \"\" or targetName == \"\" or newNameRaw == \"\" or newEffectRaw == \"\" then\n return false\n end\n\n local targetIndex = getHeroineIndexByName(triggerId, nameText)\n\n if targetIndex < 1 then\n return false\n end\n\n local codeSlotList, currentNums = getCodeSlotState(targetIndex)\n\n local slotPos, oldEffectIndex, oldName, oldType, oldDesc = findEffectByName(currentNums, targetName)\n\n -- Target Name이 기존 효과와 매칭되지 않으면 저장하지 않고 해당 줄 제거.\n if slotPos < 1 or oldEffectIndex < 1 then\n return false\n end\n\n local finalName = oldName\n local finalDesc = oldDesc\n\n if isUnchangedValue(newNameRaw) ~= true then\n finalName = newNameRaw\n end\n\n if isUnchangedValue(newEffectRaw) ~= true then\n finalDesc = newEffectRaw\n end\n\n if finalName == \"\" or finalDesc == \"\" or oldType == \"\" then\n return false\n end\n\n -- Name과 Effect가 둘 다 그대로면 실제 변경이 아니므로 해당 줄 제거.\n if finalName == oldName and finalDesc == oldDesc then\n return false\n end\n\n -- 이름이 바뀌었는데 새 이름이 이미 다른 효과로 존재하면 중복 방지.\n if finalName ~= oldName then\n local duplicateSlotPos = findEffectByName(currentNums, finalName)\n\n if duplicateSlotPos > 0 and duplicateSlotPos ~= slotPos then\n return false\n end\n end\n\n local contentText = finalName .. \"^\" .. oldType .. \"^\" .. finalDesc\n local newEffectIndex = appendEffectContentUntracked(contentText)\n\n if newEffectIndex < 1 then\n return false\n end\n\n -- 기존 Content는 수정하지 않고, 히로인의 Code만 새 Content index로 교체.\n currentNums[slotPos] = tostring(newEffectIndex)\n saveCodeSlotState(targetIndex, codeSlotList, currentNums)\n\n if finalName ~= oldName then\n -- 이름 변경은 기존 효과 종료 + 새 효과 획득으로 기록한다.\n appendSexualEffectMemory(nameText, constMemoryType.sexualEffect_Remove, oldName)\n appendSexualEffectMemory(nameText, constMemoryType.sexualEffect_Add, finalName)\n\n addTempLog(iconText .. \" \" .. nameText .. \" 지속 효과 갱신 \" .. oldName .. \" → \" .. finalName)\n else\n -- 이름이 그대로면 메모리에는 남기지 않고 Temp log만 남긴다.\n addTempLog(iconText .. \" \" .. nameText .. \" 지속 효과 갱신 \" .. finalName)\n end\n\n return true\n end\n\n local function saveRemove(fields)\n local nameText = trim(fields[\"heroine name\"] or \"\")\n local iconText = cleanIcon(fields[\"icon\"] or \"\")\n local targetName = cleanEffectField(fields[\"target name\"] or \"\")\n\n if iconText == \"\" then\n iconText = \"✨\"\n end\n\n if isApplicableHeroineName(nameText) ~= true then\n return false\n end\n\n if nameText == \"\" or targetName == \"\" then\n return false\n end\n\n local targetIndex = getHeroineIndexByName(triggerId, nameText)\n\n if targetIndex < 1 then\n return false\n end\n\n local codeSlotList, currentNums = getCodeSlotState(targetIndex)\n\n local slotPos, oldEffectIndex, oldName = findEffectByName(currentNums, targetName)\n\n -- Target Name이 기존 효과와 매칭되지 않으면 저장하지 않고 해당 줄 제거.\n if slotPos < 1 or oldEffectIndex < 1 then\n return false\n end\n\n -- Content는 삭제하지 않고, 히로인의 Code에서만 제거.\n table.remove(currentNums, slotPos)\n saveCodeSlotState(targetIndex, codeSlotList, currentNums)\n\n appendSexualEffectMemory(nameText, constMemoryType.sexualEffect_Remove, oldName)\n\n addTempLog(iconText .. \" \" .. nameText .. \" 지속 효과 제거 \" .. oldName)\n\n return true\n end\n\n local function saveRecord(recordRaw)\n recordRaw = trim(tostring(recordRaw or \"\"))\n\n if recordRaw == \"\" then\n return false\n end\n\n recordRaw = trim(recordRaw:gsub(\"^%[\", \"\"):gsub(\"%]$\", \"\"))\n\n local fields = parseOrderedFieldsWithFallback(recordRaw, {\n \"Action\",\n \"Heroine Name\",\n \"Icon\",\n \"Target Name\",\n \"Name\",\n \"Type\",\n \"Effect\",\n })\n\n local actionText = cleanEffectField(fields[\"action\"] or \"\")\n\n if actionText == \"Add\" then\n return saveAdd(fields)\n elseif actionText == \"Update\" then\n return saveUpdate(fields)\n elseif actionText == \"Remove\" then\n return saveRemove(fields)\n end\n\n return false\n end\n\n cleanMsg = cleanMsg:gsub(\n \"(<Imposed Sexual Effect[^>]*>)([%s%S]-)(</Imposed Sexual Effect>)\",\n function(openTag, body, closeTag)\n local keptRecords = {}\n local hasBracketEntry = false\n\n for recordRaw in tostring(body):gmatch(\"%b[]\") do\n hasBracketEntry = true\n\n if saveRecord(recordRaw) == true then\n table.insert(keptRecords, recordRaw)\n end\n end\n\n if hasBracketEntry ~= true then\n for line in tostring(body):gmatch(\"[^\\r\\n]+\") do\n line = trim(line)\n\n if line ~= \"\" and saveRecord(line) == true then\n table.insert(keptRecords, line)\n end\n end\n end\n\n -- 유효한 기록이 하나도 없으면 블록 자체를 제거한다.\n if #keptRecords <= 0 then\n return \"\"\n end\n\n -- 유효한 기록은 원문에 남긴다.\n return openTag .. \"\\n\" .. table.concat(keptRecords, \"\\n\") .. \"\\n\" .. closeTag\n end\n )\n\n cleanMsg = cleanMsg:gsub(\"\\n%s*\\n%s*\\n+\", \"\\n\\n\")\n\n return cleanMsg, tempLogs\nend\n\n-- ==========================================\n-- 파싱 이벤트 요약\n-- ==========================================\nfunction parseEventSummaryRecordBlocks(triggerId, cleanMsg, changedVars)\n cleanMsg = tostring(cleanMsg or \"\")\n\n local tempLogs = {}\n local foundBlock = false\n\n local targetNameMap = {}\n local targetRaw = safeGet(triggerId, \"hr_Summary_Target\", \"\")\n targetRaw = tostring(targetRaw or \"\"):gsub(\"\\r\\n\", \",\"):gsub(\"\\r\", \",\"):gsub(\"\\n\", \",\")\n\n for _, name in ipairs(splitLiteral(targetRaw, \",\")) do\n name = trim(name)\n\n if name ~= \"\" then\n targetNameMap[name] = true\n end\n end\n\n local function cleanEventSummaryField(text)\n text = trim(tostring(text or \"\"))\n text = text:gsub(\"\\r\\n\", \" \")\n text = text:gsub(\"\\r\", \" \")\n text = text:gsub(\"\\n\", \" \")\n text = text:gsub(\"|\", \"/\")\n text = text:gsub(\"%^\", \"/\")\n text = text:gsub(\"§\", \"/\")\n text = text:gsub(\"=\", \"/\")\n text = text:gsub(\"%s+\", \" \")\n return trim(text)\n end\n\n local function getSummaryTargetCode(nameText)\n nameText = trim(tostring(nameText or \"\"))\n\n if nameText == \"\" then\n return \"\"\n end\n\n -- 실제 히로인 이름이 그대로 들어온 경우\n local directCode = getCharacterCodeByName(triggerId, nameText)\n if directCode ~= \"\" then\n return directCode\n end\n\n -- hr_Heroine_Search의 활성화 키/별칭으로 들어온 경우\n local heroineCodes = getHeroineCodeList(triggerId)\n local searchSlots = splitLiteral(safeGet(triggerId, \"hr_Heroine_Search\", \"\"), \"|\")\n\n for i, code in ipairs(heroineCodes) do\n local searchText = trim(searchSlots[i] or \"\")\n\n for _, key in ipairs(splitLiteral(searchText, \"^\")) do\n key = trim(key)\n\n if key ~= \"\" and key == nameText then\n return code\n end\n end\n end\n\n return \"\"\n end\n\n if next(targetNameMap) == nil then\n cleanMsg = cleanMsg:gsub(\"<Event Summary[^>]*>%s*[%s%S]-%s*</Event Summary>\", \"\")\n cleanMsg = cleanMsg:gsub(\"\\n%s*\\n%s*\\n+\", \"\\n\\n\")\n return cleanMsg, tempLogs\n end\n\n for body in cleanMsg:gmatch(\"<Event Summary[^>]*>%s*([%s%S]-)%s*</Event Summary>\") do\n foundBlock = true\n\n for recordRaw in body:gmatch(\"%b[]\") do\n repeat\n recordRaw = trim(tostring(recordRaw or \"\"))\n\n if recordRaw == \"\" then break end\n\n recordRaw = trim(recordRaw:gsub(\"^%[\", \"\"):gsub(\"%]$\", \"\"))\n\n local fields = parseOrderedFieldsWithFallback(recordRaw, {\n \"Date\",\n \"Heroine Name\",\n \"Summary\",\n \"Brief Summary\",\n })\n\n local dateText = trim(fields[\"date\"] or \"\")\n local nameText = trim(fields[\"heroine name\"] or \"\")\n local summaryText = cleanEventSummaryField(fields[\"summary\"] or \"\")\n local briefSummaryText = cleanEventSummaryField(fields[\"brief summary\"] or \"\")\n\n if dateText == \"\" or nameText == \"\" or summaryText == \"\" then break end\n\n if targetNameMap[nameText] ~= true then\n break\n end\n\n local startDateText, endDateText = dateText:match(\"^%s*(.-)%s*~%s*(.-)%s*$\")\n\n if startDateText and endDateText then\n local startDate = parseDateText(startDateText)\n local endDate = parseDateText(endDateText)\n\n if not startDate or not endDate then break end\n\n dateText =\n formatDateTextWithHyphen(startDateText) ..\n \" ~ \" ..\n formatDateTextWithHyphen(endDateText)\n else\n local dateTable = parseDateText(dateText)\n\n if not dateTable then break end\n\n dateText = formatDateTextWithHyphen(dateText)\n end\n\n local targetCode = getSummaryTargetCode(nameText)\n\n if targetCode == \"\" then break end\n\n appendMemoryEntry(triggerId, targetCode, constMemoryType.eventSummary, dateText, \"\", summaryText, briefSummaryText, changedVars)\n table.insert(tempLogs, \"📝 \" .. nameText .. \" \" .. dateText .. \" 이벤트 요약\")\n until true\n end\n end\n\n if foundBlock == true then\n setChatVar(triggerId, \"hr_Summary_Event\", \"\")\n setChatVar(triggerId, \"hr_Summary_Target\", \"\")\n end\n\n cleanMsg = cleanMsg:gsub(\"<Event Summary[^>]*>%s*[%s%S]-%s*</Event Summary>\", \"\")\n cleanMsg = cleanMsg:gsub(\"\\n%s*\\n%s*\\n+\", \"\\n\\n\")\n\n return cleanMsg, tempLogs\nend\n\n-- ==========================================\n-- 파싱 루트 선택지\n-- ==========================================\nfunction parseRouteChoiceTexts(triggerId, outputText)\n outputText = tostring(outputText or \"\")\n\n local choiceTexts = {}\n\n local function routeChoiceTypeToPhase(value)\n local key = trim(tostring(value or \"\"))\n key = key:lower()\n key = key:gsub(\"[_%-%s]+\", \"\")\n\n if key == \"incapacitated\"\n or key == \"trapped\"\n or key == \"stranded\"\n or key == \"bargained\" then\n return constPhase.event_Incapacitated\n end\n\n return constPhase.event\n end\n\n local function cleanRouteChoiceValue(value)\n local s = sanitizeChoiceFieldValue(stripOuterLineQuotes(value))\n s = s:gsub(\"_\", \"/\")\n s = s:gsub(\"%s+\", \" \")\n return trim(s)\n end\n\n local routeChoiceRaw = outputText:match(\n \"<Route Choice>%s*([%s%S]-)%s*</Route Choice>\"\n )\n\n if routeChoiceRaw and trim(routeChoiceRaw) ~= \"\" then\n local routeMain = trim(safeGet(triggerId, \"hr_Route_Main\", \"\"))\n setChatVar(triggerId, \"hr_Route_Main\", \"\")\n\n local records = collectBracketRecordsFromBody(routeChoiceRaw, \"lines\")\n\n for _, recordRaw in ipairs(records) do\n local raw = normalizeRecordRaw(recordRaw)\n\n if raw ~= \"\" then\n local fields = parseOrderedFieldsWithFallback(raw, {\n \"Mode\",\n \"Icon\",\n \"Time\",\n \"Location\",\n \"Setup\",\n \"Type\",\n })\n\n local mode = cleanRouteChoiceValue(fields[\"mode\"])\n local icon = cleanRouteChoiceValue(fields[\"icon\"])\n local time = cleanRouteChoiceValue(fields[\"time\"])\n local location = cleanRouteChoiceValue(fields[\"location\"])\n local setup = cleanRouteChoiceValue(fields[\"setup\"])\n local typeText = cleanRouteChoiceValue(fields[\"type\"])\n local phaseValue = routeChoiceTypeToPhase(typeText)\n\n if phaseValue == constPhase.event_Incapacitated then\n icon = \"🔞\"\n end\n\n if routeMain ~= \"\"\n and mode ~= \"\"\n and icon ~= \"\"\n and time ~= \"\"\n and location ~= \"\"\n and setup ~= \"\"\n and phaseValue ~= constPhase.none\n then\n table.insert(\n choiceTexts,\n \"[Route Choice: \" .. routeMain ..\n \" | Mode: \" .. mode ..\n \" | Icon: \" .. icon ..\n \" | Time: \" .. time ..\n \" | Location: \" .. location ..\n \" | Setup: \" .. setup ..\n \" | Type: \" .. tostring(phaseValue) ..\n \"]\"\n )\n end\n end\n end\n end\n\n outputText = stripNamedTagBlockAndNormalize(outputText, \"Route Choice\")\n\n return outputText, choiceTexts\nend\n\n-- ==========================================\n-- 파싱 자위\n-- ==========================================\nfunction parseMasturbationChoiceTexts(triggerId, outputText)\n outputText = tostring(outputText or \"\")\n\n local choiceTexts = {}\n\n local function cleanMasturbationChoiceValue(value)\n local s = sanitizeChoiceFieldValue(stripOuterLineQuotes(value))\n s = s:gsub(\"_\", \"/\")\n s = s:gsub(\"%s+\", \" \")\n return trim(s)\n end\n\n local masturbationChoiceRaw = outputText:match(\n \"<Heroine Masturbation Choice>%s*([%s%S]-)%s*</Heroine Masturbation Choice>\"\n )\n\n if masturbationChoiceRaw and trim(masturbationChoiceRaw) ~= \"\" then\n local records = collectBracketRecordsFromBody(masturbationChoiceRaw, \"lines\")\n\n for _, recordRaw in ipairs(records) do\n local raw = normalizeRecordRaw(recordRaw)\n\n if raw ~= \"\" then\n local fields = parseOrderedFieldsWithFallback(raw, {\n \"Action\",\n \"Icon\",\n \"Focus\",\n })\n\n local action = cleanMasturbationChoiceValue(fields[\"action\"])\n local icon = cleanMasturbationChoiceValue(fields[\"icon\"])\n local focus = cleanMasturbationChoiceValue(fields[\"focus\"])\n\n if action ~= \"\" and icon ~= \"\" and focus ~= \"\" then\n table.insert(\n choiceTexts,\n \"[Masturbation Choice: \" .. action ..\n \" | Icon: \" .. icon ..\n \" | Focus: \" .. focus ..\n \"]\"\n )\n end\n end\n end\n end\n\n if #choiceTexts > 0 then\n table.insert(choiceTexts, \"[Blank Line]\")\n local masturbationEndChoiceIcon = \"⏹️\"\n\n table.insert(\n choiceTexts,\n \"[Masturbation End Choice: 절정 후 자위 중단\" ..\n \" | Icon: \" .. masturbationEndChoiceIcon ..\n \"]\"\n )\n end\n\n outputText = stripNamedTagBlockAndNormalize(outputText, \"Heroine Masturbation Choice\")\n\n return outputText, choiceTexts\nend\n\n-- ==========================================\n-- 파싱 이벤트 목적\n-- ==========================================\nfunction parseEventObjectiveBlock(triggerId, outputText, changedVars)\n outputText = tostring(outputText or \"\")\n\n local function cleanEventObjectiveText(value)\n local s = sanitizeChoiceFieldValue(stripOuterLineQuotes(value))\n\n s = s:gsub(\"\\r\\n\", \" \")\n s = s:gsub(\"\\r\", \" \")\n s = s:gsub(\"\\n\", \" \")\n\n -- hr_Session_Status 저장 구분자 보호\n s = s:gsub(\"|\", \"/\")\n s = s:gsub(\"%^\", \"/\")\n s = s:gsub(\"§\", \"/\")\n s = s:gsub(\"=\", \"/\")\n\n s = s:gsub(\"%s+\", \" \")\n return trim(s)\n end\n\n local objectiveRaw = outputText:match(\n \"<Event Objective[^>]*>%s*([%s%S]-)%s*</Event Objective>\"\n )\n\n if objectiveRaw and trim(objectiveRaw) ~= \"\" then\n local records = collectBracketRecordsFromBody(objectiveRaw, \"lines\")\n\n for _, recordRaw in ipairs(records) do\n local raw = normalizeRecordRaw(recordRaw)\n\n if raw ~= \"\" then\n local fields = parseOrderedFieldsWithFallback(raw, {\n \"Objective\",\n })\n\n local objective = cleanEventObjectiveText(fields[\"objective\"])\n setChatVar(triggerId, \"hr_Event_Objective\", objective)\n\n if objective ~= \"\" then\n setDualFlag(triggerId, hrSessionStatus.varName, hrSessionStatus.objective, objective, false)\n\n if changedVars then\n changedVars[hrSessionStatus.varName] = safeGet(triggerId, hrSessionStatus.varName, \"\")\n end\n\n break\n end\n end\n end\n end\n\n outputText = stripNamedTagBlockAndNormalize(outputText, \"Event Objective\")\n\n return outputText\nend\n\n-- ==========================================\n-- 파싱 이벤트 전개 선택지\n-- ==========================================\nfunction parseEventDevelopChoiceTexts(triggerId, outputText, curPhase)\n outputText = tostring(outputText or \"\")\n curPhase = tonumber(curPhase) or 0\n\n local choiceTexts = {}\n local eventByOrder = {}\n local heroineByOrder = { [1] = {}, [2] = {} }\n\n local function cleanEventChoiceText(value)\n local s = trim(tostring(value or \"\"))\n\n s = s:gsub(\"^%s*%[\", \"\"):gsub(\"%]%s*$\", \"\")\n\n -- 따옴표는 겉뿐 아니라 값 안쪽에 있어도 전부 제거\n s = s:gsub('\"', \"\")\n s = s:gsub(\"'\", \"\")\n s = s:gsub(\"“\", \"\"):gsub(\"”\", \"\")\n s = s:gsub(\"‘\", \"\"):gsub(\"’\", \"\")\n\n s = s:gsub(\"\\r\\n\", \" \")\n s = s:gsub(\"\\r\", \" \")\n s = s:gsub(\"\\n\", \" \")\n s = s:gsub(\"|\", \"/\")\n s = s:gsub(\"§\", \"/\")\n s = s:gsub(\"%s+\", \" \")\n\n return trim(s)\n end\n\n local function normalizeOutcomeTypeKey(value)\n local s = trim(tostring(value or \"\"))\n s = s:lower()\n s = s:gsub(\"%s+\", \" \")\n s = s:gsub(\"[_%-%s]+\", \"\")\n return trim(s)\n end\n\n local function eventOutcomeTypeToCode(value, order)\n local key = normalizeOutcomeTypeKey(value)\n order = tonumber(order) or 0\n\n -- Order 1에서는 종료 Outcome 금지.\n if order == 1 then\n if key == \"resolution\" then\n key = \"favorable\"\n elseif key == \"closure\" or key == \"incapacitated\" then\n key = \"setback\"\n end\n end\n\n local map = {\n favorable = constOutcome.event_Favorable,\n setback = constOutcome.event_Setback,\n molestation = constOutcome.event_Molestation,\n resolution = constOutcome.event_Resolution,\n closure = constOutcome.event_Closure,\n incapacitated = constOutcome.event_Incapacitated,\n }\n\n return map[key] or constOutcome.none\n end\n\n local function collectBracketRecords(body)\n local records = {}\n body = tostring(body or \"\")\n\n for recordRaw in body:gmatch(\"%b[]\") do\n table.insert(records, recordRaw)\n end\n\n if #records <= 0 then\n for line in (body .. \"\\n\"):gmatch(\"(.-)\\n\") do\n line = trim(line)\n\n if line ~= \"\" then\n table.insert(records, line)\n end\n end\n end\n\n return records\n end\n\n local mainHeroine = safeGet(triggerId, \"hr_Event_Main\", \"\")\n\n local eventRaw = outputText:match(\"<Event Development>%s*([%s%S]-)%s*</Event Development>\")\n if eventRaw and trim(eventRaw) ~= \"\" then\n local records = collectBracketRecords(eventRaw)\n\n for _, recordRaw in ipairs(records) do\n recordRaw = trim(tostring(recordRaw or \"\"))\n recordRaw = trim(recordRaw:gsub(\"^%[\", \"\"):gsub(\"%]$\", \"\"))\n\n local fields = parseOrderedFieldsWithFallback(recordRaw, {\n \"Order\",\n \"Source\",\n \"Icon\",\n \"Development\",\n })\n\n local order = extractInt(fields[\"order\"])\n local source = normalizeIndexedSourceName(cleanEventChoiceText(fields[\"source\"]))\n local icon = cleanEventChoiceText(fields[\"icon\"])\n local development = cleanEventChoiceText(fields[\"development\"])\n\n if order >= 1 and order <= 2\n and eventByOrder[order] == nil\n and source ~= \"\"\n and icon ~= \"\"\n and development ~= \"\"\n then\n eventByOrder[order] = {\n order = order,\n source = source,\n icon = icon,\n development = development,\n }\n end\n end\n end\n\n local heroineRaw = outputText:match(\"<Heroine Attempt Choice>%s*([%s%S]-)%s*</Heroine Attempt Choice>\")\n if heroineRaw and trim(heroineRaw) ~= \"\" then\n local records = collectBracketRecords(heroineRaw)\n\n for _, recordRaw in ipairs(records) do\n recordRaw = trim(tostring(recordRaw or \"\"))\n recordRaw = trim(recordRaw:gsub(\"^%[\", \"\"):gsub(\"%]$\", \"\"))\n\n local fields = parseOrderedFieldsWithFallback(recordRaw, {\n \"Order\",\n \"Attempt\",\n \"Icon\",\n \"Outcome Type\",\n \"Result\",\n })\n\n local order = extractInt(fields[\"order\"])\n local attempt = cleanEventChoiceText(fields[\"attempt\"])\n local icon = cleanEventChoiceText(fields[\"icon\"])\n local outcomeTypeRaw = cleanEventChoiceText(fields[\"outcome type\"])\n local result = cleanEventChoiceText(fields[\"result\"])\n\n local outcomeType = outcomeTypeRaw\n local outcomeValid = outcomeTypeRaw ~= \"\"\n\n outcomeType = eventOutcomeTypeToCode(outcomeTypeRaw, order)\n outcomeValid = outcomeType ~= constOutcome.none\n\n if order >= 1 and order <= 2\n and #heroineByOrder[order] < 3\n and attempt ~= \"\"\n and icon ~= \"\"\n and outcomeValid == true\n and result ~= \"\"\n then\n table.insert(heroineByOrder[order], {\n order = order,\n attempt = attempt,\n icon = icon,\n outcomeType = outcomeType,\n result = result,\n })\n end\n end\n end\n\n local sideActions = {}\n\n local sideRaw = outputText:match(\"<Heroine Side Action>%s*([%s%S]-)%s*</Heroine Side Action>\")\n if sideRaw and trim(sideRaw) ~= \"\" then\n local records = collectBracketRecords(sideRaw)\n\n for _, recordRaw in ipairs(records) do\n recordRaw = trim(tostring(recordRaw or \"\"))\n recordRaw = trim(recordRaw:gsub(\"^%[\", \"\"):gsub(\"%]$\", \"\"))\n\n local fields = parseOrderedFieldsWithFallback(recordRaw, {\n \"Actor\",\n \"Action\",\n \"Icon\",\n })\n\n local actor = normalizeIndexedSourceName(cleanEventChoiceText(fields[\"actor\"]))\n local action = cleanEventChoiceText(fields[\"action\"])\n local icon = cleanEventChoiceText(fields[\"icon\"])\n\n if #sideActions < 2\n and actor ~= \"\"\n and (mainHeroine == \"\" or actor ~= mainHeroine)\n and action ~= \"\"\n and icon ~= \"\"\n then\n table.insert(sideActions, {\n actor = actor,\n action = action,\n icon = icon,\n })\n end\n end\n end\n\n for order = 1, 2 do\n local eventData = eventByOrder[order]\n local orderText = tostring(order)\n\n if eventData then\n table.insert(\n choiceTexts,\n \"[Event Development: \" .. orderText ..\n \" | Source: \" .. eventData.source ..\n \" | Icon: \" .. eventData.icon ..\n \" | Development: \" .. eventData.development ..\n \"]\"\n )\n end\n\n local heroineChoices = heroineByOrder[order] or {}\n\n if #heroineChoices > 1 then\n shuffleArray(heroineChoices)\n end\n\n for _, data in ipairs(heroineChoices) do\n table.insert(\n choiceTexts,\n \"[Heroine Side Choice: \" .. orderText ..\n \" | Attempt: \" .. data.attempt ..\n \" | Icon: \" .. data.icon ..\n \" | Outcome Type: \" .. tostring(data.outcomeType) ..\n \" | Result: \" .. data.result ..\n \"]\"\n )\n end\n\n if order == 1 and (eventByOrder[2] ~= nil or #(heroineByOrder[2] or {}) > 0) then\n table.insert(choiceTexts, \"[Blank Line]\")\n end\n end\n\n if #sideActions > 0 then\n if #choiceTexts > 0 then\n table.insert(choiceTexts, \"[Blank Line]\")\n end\n\n for _, data in ipairs(sideActions) do\n table.insert(\n choiceTexts,\n \"[Heroine Side Action: \" .. data.actor ..\n \" | Action: \" .. data.action ..\n \" | Icon: \" .. data.icon ..\n \"]\"\n )\n end\n end\n\n outputText = outputText:gsub(\"<Event Development>%s*[%s%S]-%s*</Event Development>\", \"\")\n outputText = outputText:gsub(\"<Heroine Attempt Choice>%s*[%s%S]-%s*</Heroine Attempt Choice>\", \"\")\n outputText = outputText:gsub(\"<Heroine Side Action>%s*[%s%S]-%s*</Heroine Side Action>\", \"\")\n outputText = outputText:gsub(\"\\n%s*\\n%s*\\n+\", \"\\n\\n\")\n\n return outputText, choiceTexts\nend\n\n-- ==========================================\n-- 파싱 H씬\n-- ==========================================\nfunction parseHSceneChoiceTexts(triggerId, outputText)\n outputText = tostring(outputText or \"\")\n\n local choiceTexts = {}\n\n local hSceneChoiceRaw = outputText:match(\n \"<H Scene Choice>%s*([%s%S]-)%s*</H Scene Choice>\"\n )\n\n if hSceneChoiceRaw and trim(hSceneChoiceRaw) ~= \"\" then\n local records = collectBracketRecordsFromBody(hSceneChoiceRaw, \"lines\")\n\n for _, recordRaw in ipairs(records) do\n local raw = normalizeRecordRaw(recordRaw)\n\n if raw ~= \"\" then\n local fields = parseOrderedFieldsWithFallback(raw, {\n \"Heroine Name\",\n \"Title\",\n \"Icon\",\n \"Location\",\n \"Setup\",\n })\n\n local heroineName = sanitizeChoiceFieldValue(stripOuterLineQuotes(fields[\"heroine name\"]))\n local title = sanitizeChoiceFieldValue(stripOuterLineQuotes(fields[\"title\"]))\n local icon = sanitizeChoiceFieldValue(stripOuterLineQuotes(fields[\"icon\"]))\n local location = sanitizeChoiceFieldValue(stripOuterLineQuotes(fields[\"location\"]))\n local setup = sanitizeChoiceFieldValue(stripOuterLineQuotes(fields[\"setup\"]))\n\n if heroineName ~= \"\" and title ~= \"\" and icon ~= \"\" and location ~= \"\" and setup ~= \"\" then\n table.insert(\n choiceTexts,\n \"[H Scene Choice: \" .. heroineName ..\n \" | Title: \" .. title ..\n \" | Icon: \" .. icon ..\n \" | Location: \" .. location ..\n \" | Setup: \" .. setup ..\n \"]\"\n )\n end\n end\n end\n end\n\n outputText = stripNamedTagBlockAndNormalize(outputText, \"H Scene Choice\")\n\n return outputText, choiceTexts\nend\n\nfunction parseViolationChoiceTexts(triggerId, outputText)\n outputText = tostring(outputText or \"\")\n\n local choiceTexts, threatsByOrder = {}, {}\n local heroineByOrder = { [1] = {}, [2] = {} }\n\n local function cleanViolationText(value)\n local s = sanitizeChoiceFieldValue(value)\n\n -- 따옴표는 겉뿐 아니라 값 안쪽에 있어도 전부 제거\n s = s:gsub('\"', \"\")\n s = s:gsub(\"'\", \"\")\n s = s:gsub(\"“\", \"\"):gsub(\"”\", \"\")\n s = s:gsub(\"‘\", \"\"):gsub(\"’\", \"\")\n\n s = s:gsub(\"%s+\", \" \")\n return trim(s)\n end\n\n local function cleanViolationActionText(value)\n local s = cleanViolationText(value)\n return trim(s)\n end\n \n local threatRaw = outputText:match(\"<Violation Threat>%s*([%s%S]-)%s*</Violation Threat>\")\n if threatRaw and trim(threatRaw) ~= \"\" then\n local records = collectBracketRecordsFromBody(threatRaw, \"lines\")\n\n for _, recordRaw in ipairs(records) do\n local raw = normalizeRecordRaw(recordRaw)\n local fields = parseOrderedFieldsWithFallback(raw, {\n \"Order\",\n \"Actor\",\n \"Icon\",\n \"Intent\",\n \"Intent Sexual Act\",\n \"Intent Focus\"\n })\n\n local order = extractInt(fields[\"order\"])\n local actor = normalizeIndexedSourceName(cleanViolationText(fields[\"actor\"]))\n local icon = cleanViolationText(fields[\"icon\"])\n local intent = cleanViolationText(fields[\"intent\"])\n local intentSexualAct = cleanViolationText(fields[\"intent sexual act\"])\n local intentFocus = cleanViolationText(fields[\"intent focus\"])\n\n if order >= 1 and order <= 2\n and threatsByOrder[order] == nil\n and actor ~= \"\"\n and icon ~= \"\"\n and intent ~= \"\"\n and intentSexualAct ~= \"\"\n and intentFocus ~= \"\"\n then\n threatsByOrder[order] = {\n order = order,\n actor = actor,\n icon = icon,\n intent = intent,\n intentSexualAct = intentSexualAct,\n intentFocus = intentFocus\n }\n end\n end\n end\n\n local heroineRaw = outputText:match(\"<Heroine Violation Choice>%s*([%s%S]-)%s*</Heroine Violation Choice>\")\n if heroineRaw and trim(heroineRaw) ~= \"\" then\n local records = collectBracketRecordsFromBody(heroineRaw, \"lines\")\n\n for _, recordRaw in ipairs(records) do\n local raw = normalizeRecordRaw(recordRaw)\n local fields = parseOrderedFieldsWithFallback(raw, {\n \"Order\",\n \"Action\",\n \"Icon\",\n \"Path\",\n \"Resulting Sexual Act\",\n \"Resulting Focus\"\n })\n\n local order = extractInt(fields[\"order\"])\n local action = cleanViolationActionText(fields[\"action\"])\n local icon = cleanViolationText(fields[\"icon\"])\n local path = cleanViolationText(fields[\"path\"])\n local resultingSexualAct = cleanViolationText(fields[\"resulting sexual act\"])\n local resultingFocus = cleanViolationText(fields[\"resulting focus\"])\n\n if order >= 1 and order <= 2\n and #heroineByOrder[order] < 3\n and action ~= \"\"\n and icon ~= \"\"\n and path ~= \"\"\n and resultingSexualAct ~= \"\"\n and resultingFocus ~= \"\"\n then\n table.insert(heroineByOrder[order], {\n order = order,\n action = action,\n icon = icon,\n path = path,\n resultingSexualAct = resultingSexualAct,\n resultingFocus = resultingFocus\n })\n end\n end\n end\n\n for order = 1, 2 do\n local threat = threatsByOrder[order]\n local orderText, orderEmoji = tostring(order), getOrderEmoji(order)\n\n if threat then\n table.insert(\n choiceTexts,\n \"[Violation Threat: \" .. orderText ..\n \" | Order Emoji: \" .. orderEmoji ..\n \" | Actor: \" .. threat.actor ..\n \" | Icon: \" .. threat.icon ..\n \" | Intent: \" .. threat.intent ..\n \" | Intent Sexual Act: \" .. threat.intentSexualAct ..\n \" | Intent Focus: \" .. threat.intentFocus ..\n \"]\"\n )\n end\n\n local heroineChoices = heroineByOrder[order] or {}\n\n if #heroineChoices > 1 then\n shuffleArray(heroineChoices)\n end\n\n for _, data in ipairs(heroineChoices) do\n table.insert(\n choiceTexts,\n \"[Heroine Violation Choice: \" .. orderText ..\n \" | Action: \" .. data.action ..\n \" | Icon: \" .. data.icon ..\n \" | Resulting Sexual Act: \" .. data.resultingSexualAct ..\n \" | Resulting Focus: \" .. data.resultingFocus ..\n \"]\"\n )\n end\n\n if order == 1 and (threatsByOrder[2] ~= nil or #(heroineByOrder[2] or {}) > 0) then\n table.insert(choiceTexts, \"[Blank Line]\")\n end\n end\n\n local turn = tonumber(getResolvedDualFlag(triggerId, hrSessionStatus.varName, hrSessionStatus.turn, 0)) or 0\n\n if turn >= 2 then\n if #choiceTexts > 0 then\n table.insert(choiceTexts, \"[Blank Line]\")\n end\n\n table.insert(choiceTexts, \"[H Scene End: ⏹️ 상황 종료]\")\n end\n\n outputText = stripNamedTagBlockAndNormalize(outputText, \"Violation Threat\")\n outputText = stripNamedTagBlockAndNormalize(outputText, \"Heroine Violation Choice\")\n\n return outputText, choiceTexts\nend\n\nfunction parseHSceneEndTexts(triggerId, outputText)\n outputText = tostring(outputText or \"\")\n\n local choiceTexts = {}\n\n -- ==========================================\n -- Fate Choice\n -- ==========================================\n local fateChoiceRaw = outputText:match(\n \"<Fate Choice>%s*([%s%S]-)%s*</Fate Choice>\"\n )\n\n if fateChoiceRaw and trim(fateChoiceRaw) ~= \"\" then\n local records = collectBracketRecordsFromBody(fateChoiceRaw, \"lines\")\n\n for _, recordRaw in ipairs(records) do\n local raw = normalizeRecordRaw(recordRaw)\n\n if raw ~= \"\" then\n local fields = parseOrderedFieldsWithFallback(raw, {\n \"Heroine Name\",\n \"Type\",\n \"Icon\",\n \"Time\",\n \"Location\",\n \"Situation\",\n })\n\n local heroineName = sanitizeChoiceFieldValue(stripOuterLineQuotes(fields[\"heroine name\"]))\n local icon = sanitizeChoiceFieldValue(stripOuterLineQuotes(fields[\"icon\"]))\n local time = sanitizeChoiceFieldValue(stripOuterLineQuotes(fields[\"time\"]))\n local location = sanitizeChoiceFieldValue(stripOuterLineQuotes(fields[\"location\"]))\n local situation = sanitizeChoiceFieldValue(stripOuterLineQuotes(fields[\"situation\"]))\n\n if heroineName ~= \"\" and icon ~= \"\" and time ~= \"\" and location ~= \"\" and situation ~= \"\" then\n table.insert(\n choiceTexts,\n \"[Fate Choice: \" .. heroineName ..\n \" | Situation: \" .. situation ..\n \" | Icon: \" .. icon ..\n \" | Time: \" .. time ..\n \" | Location: \" .. location ..\n \"]\"\n )\n end\n end\n end\n end\n\n outputText = stripNamedTagBlockAndNormalize(outputText, \"Fate Choice\")\n\n return outputText, choiceTexts\nend\n\nfunction triggerHsceneEnd(triggerId)\n local REQUIRED_VIOLATION_COUNT = 2\n\n local function parseChoiceFieldsLocal(entry)\n local raw = trim(tostring(entry or \"\"))\n raw = raw:gsub(\"^%[\", \"\"):gsub(\"%]$\", \"\")\n\n local fields = {}\n\n for part in raw:gmatch(\"([^|]+)\") do\n local key, value = part:match(\"^%s*(.-)%s*:%s*(.-)%s*$\")\n\n if key and key ~= \"\" then\n fields[string.lower(trim(key))] = trim(value or \"\")\n end\n end\n\n return fields\n end\n\n local function countViolationSelectOrders(text)\n local selectedOrders = {}\n local count = 0\n\n for entry in tostring(text or \"\"):gmatch(\"%b[]\") do\n local fields = parseChoiceFieldsLocal(entry)\n local order = extractInt(fields[\"heroine violation select\"] or \"\")\n\n if order >= 1 and order <= REQUIRED_VIOLATION_COUNT and not selectedOrders[order] then\n selectedOrders[order] = true\n count = count + 1\n end\n end\n\n return count\n end\n\n local cl = getChatLength(triggerId)\n\n if cl <= 0 then\n alertError(triggerId, \"❌ 먼저 선택지를 모두 선택한 후 버튼을 눌러 주세요.\")\n return\n end\n\n local lastIndex = cl - 1\n local last = getChat(triggerId, lastIndex)\n\n if not last or last.role ~= \"user\" or type(last.data) ~= \"string\" then\n alertError(triggerId, \"❌ 먼저 선택지를 모두 선택한 후 버튼을 눌러 주세요.\")\n return\n end\n\n local lastText = tostring(last.data or \"\")\n local selectedCount = countViolationSelectOrders(lastText)\n\n if selectedCount < REQUIRED_VIOLATION_COUNT then\n alertError(triggerId, \"❌ 먼저 선택지를 모두 선택한 후 버튼을 눌러 주세요.\")\n return\n end\n\n do\n local function cleanEventSummaryText(value)\n local s = trim(tostring(value or \"\"))\n\n s = s:gsub(\"\\r\\n\", \" \")\n s = s:gsub(\"\\r\", \" \")\n s = s:gsub(\"\\n\", \" \")\n s = s:gsub(\"%s+\", \" \")\n\n return trim(s)\n end\n\n local function normalizeEventRecordType(value)\n local s = trim(tostring(value or \"\"))\n s = s:lower()\n s = s:gsub(\"[_%-%s]+\", \"\")\n\n if s == \"eventstart\" then\n return \"Event Start\"\n elseif s == \"incapacitation\" then\n return \"Incapacitation\"\n elseif s == \"transition\" then\n return \"Transition\"\n elseif s == \"progress\" then\n return \"Progress\"\n end\n\n return \"\"\n end\n\n local function parseEventRecordFromText(text)\n text = tostring(text or \"\")\n\n local body = text:match(\"<Event Record[^>]*>%s*([%s%S]-)%s*</Event Record>\")\n if not body then\n return nil\n end\n\n local recordRaw = body:match(\"%b[]\") or body\n recordRaw = trim(tostring(recordRaw or \"\"))\n recordRaw = trim(recordRaw:gsub(\"^%[\", \"\"):gsub(\"%]$\", \"\"))\n\n if recordRaw == \"\" then\n return nil\n end\n\n local fields = parseOrderedFieldsWithFallback(recordRaw, {\n \"Record Type\",\n \"Summary\",\n })\n\n local recordType = normalizeEventRecordType(fields[\"record type\"] or \"\")\n local summary = cleanEventSummaryText(fields[\"summary\"] or \"\")\n\n if recordType == \"\" or summary == \"\" then\n return nil\n end\n\n return {\n recordType = recordType,\n summary = summary,\n }\n end\n\n local function buildSummaryEventText()\n local history = getFullChat(triggerId)\n if not history or #history <= 0 then\n return \"\"\n end\n\n local MAX_EVENT_START_LOOKBACK = 3\n\n local reversedRecords = {}\n\n local foundIncapacitation = false\n local foundEventStart = false\n local lookbackCount = 0\n\n -- 최신 char 출력부터 과거 방향으로 이동\n for i = #history, 1, -1 do\n local item = history[i]\n\n if item and item.role == \"char\" and type(item.data) == \"string\" and trim(item.data) ~= \"\" then\n local parseText = item.data\n\n -- Thought 블록은 Event Record 파싱 대상에서 제외\n parseText = maskThoughtBlocksForParsing(parseText)\n\n local record = parseEventRecordFromText(parseText)\n\n if record then\n if foundIncapacitation ~= true then\n -- Incapacitation을 찾기 전에는 최신 진행 기록들을 모아 둠\n table.insert(reversedRecords, record)\n\n if record.recordType == \"Incapacitation\" then\n foundIncapacitation = true\n end\n else\n lookbackCount = lookbackCount + 1\n\n -- 그 안에 Event Start가 안 나오면\n -- 실패하지 말고 Incapacitation까지만 사용\n if lookbackCount > MAX_EVENT_START_LOOKBACK then\n break\n end\n\n -- Event Start면 포함하고 종료\n if record.recordType == \"Event Start\" then\n table.insert(reversedRecords, record)\n foundEventStart = true\n break\n end\n\n -- Event Start가 아닌 다른 Record Type이면\n -- 그건 넣지 않고, Incapacitation까지만 사용\n break\n end\n elseif foundIncapacitation == true then\n -- Incapacitation 위쪽 검색 중 Event Record 없는 char도 검색 횟수에 포함\n lookbackCount = lookbackCount + 1\n\n -- 그 안에 Event Start가 안 나오면\n -- 실패하지 말고 Incapacitation까지만 사용\n if lookbackCount > MAX_EVENT_START_LOOKBACK then\n break\n end\n end\n end\n end\n\n -- Incapacitation 자체가 없으면 요약 불가\n if foundIncapacitation ~= true then\n return \"\"\n end\n\n local eventStart = \"\"\n local incapacitation = \"\"\n local transition = \"\"\n local progressList = {}\n\n -- 시간순으로 다시 정렬해서 분류\n for i = #reversedRecords, 1, -1 do\n local record = reversedRecords[i]\n\n if record.recordType == \"Event Start\" then\n if eventStart == \"\" then\n eventStart = record.summary\n end\n elseif record.recordType == \"Incapacitation\" then\n if incapacitation == \"\" then\n incapacitation = record.summary\n end\n elseif record.recordType == \"Transition\" then\n if transition == \"\" then\n transition = record.summary\n end\n elseif record.recordType == \"Progress\" then\n table.insert(progressList, record.summary)\n end\n end\n\n -- 이제 Event Start는 필수가 아님.\n -- Incapacitation만 있으면 최소 요약 생성\n if incapacitation == \"\" then\n return \"\"\n end\n\n local lines = {}\n\n if eventStart ~= \"\" then\n table.insert(lines, \"- Event Start: \" .. eventStart)\n end\n\n table.insert(lines, \"- Incapacitation: \" .. incapacitation)\n\n if transition ~= \"\" then\n table.insert(lines, \"- Transition: \" .. transition)\n end\n\n if #progressList > 0 then\n table.insert(lines, \"- Progress:\")\n for _, summary in ipairs(progressList) do\n table.insert(lines, \" \" .. summary)\n end\n end\n\n return table.concat(lines, \"\\n\")\n end\n\n local function buildSummaryTargetText()\n local raw = safeGet(triggerId, \"hr_Heroine_Search\", \"\")\n\n if raw == \"\" then\n return \"\"\n end\n\n local targets = {}\n local used = {}\n\n -- hr_Heroine_Search 형식:\n -- 히로인1키^별칭|히로인2키^별칭\n -- 이것을 전부 쉼표 목록으로 변환\n raw = raw:gsub(\"|\", \"^\")\n\n for part in (raw .. \"^\"):gmatch(\"(.-)%^\") do\n part = trim(part or \"\")\n\n if part ~= \"\" and used[part] ~= true then\n table.insert(targets, part)\n used[part] = true\n end\n end\n\n return table.concat(targets, \", \")\n end\n\n setChatVar(triggerId, \"hr_Summary_Event\", buildSummaryEventText())\n setChatVar(triggerId, \"hr_Summary_Target\", buildSummaryTargetText())\n end\n\n do\n local eventMessage = safeGet(triggerId, \"hr_Event_Message\", \"\")\n\n eventMessage = eventMessage:gsub(\n \"During this response, the sexual violation must continue%.[^\\n]*\\n?\",\n \"\"\n )\n\n setChatVar(triggerId, \"hr_Event_Message\", trim(eventMessage))\n end\n\n local eventEndMessage =\n --\"After the actions described above have resolved, near the end of this response the situation must no longer be an active sexual situation.\\n\" ..\n \"After the actions described above have resolved, describe time passing as the sexual activity continues until the opposing side is sufficiently satisfied, briefly summarizing what occurred during that time.\\n\" ..\n \"Keep it limited to what has already occurred during this active sexual situation, and do not go beyond that scope.\\n\" ..\n \"near the end of this response the situation must no longer be an active sexual situation.\\n\" ..\n \"The opposing side must still remain present or retain control, but the active violation itself must be over for now, leaving the situation unresolved and open to what happens next.\"\n\n setChatVar(triggerId, \"hr_Event_End\", eventEndMessage)\n setPhase(triggerId, constPhase.hScene_End, true)\n\n lastText = trim(lastText)\n\n local eventEndInput = \"[HR Event End: ⏹️ 상황 종료]\"\n\n if lastText ~= \"\" and lastText:find(\"[HR Event End\", 1, true) == nil then\n lastText = lastText .. \"\\n[Blank Line]\\n\" .. eventEndInput\n end\n\n setChat(triggerId, lastIndex, lastText)\nend\n\n-- ==========================================\n-- 출력\n-- ==========================================\nonOutput = async(function(triggerId)\n local tempLogs = {}\n local saveText = \"\"\n local choiceTexts = {}\n local lastTexts = {}\n\n local isFullSave = false\n\n local prevPhase = getCurrentPhase(triggerId)\n\n local function addTempLog(msg)\n if msg and msg ~= \"\" then\n table.insert(tempLogs, msg)\n end\n end\n\n local function addChoiceText(text)\n if text and text ~= \"\" then\n table.insert(choiceTexts, text)\n end\n end\n\n local function addLastText(text)\n if text and text ~= \"\" then\n table.insert(lastTexts, text)\n end\n end\n\n local function buildExtraOutput()\n local parts = {}\n\n if #tempLogs > 0 then\n for _, msg in ipairs(tempLogs) do\n msg = tostring(msg or \"\")\n msg = msg:gsub(\"%[\", \"\"):gsub(\"%]\", \"\")\n\n table.insert(parts, \"[Temporary Log: \" .. msg .. \"]\")\n end\n end\n\n if #choiceTexts > 0 then\n table.insert(parts, \"<Choice>\\n\" .. table.concat(choiceTexts, \"\\n\") .. \"\\n</Choice>\")\n end\n\n if #lastTexts > 0 then\n table.insert(parts, table.concat(lastTexts, \"\\n\"))\n end\n\n if saveText ~= \"\" then\n table.insert(parts, saveText)\n end\n\n if #parts > 0 then\n return \"\\n\" .. table.concat(parts, \"\\n\")\n end\n\n return \"\"\n end\n\n local function shouldWriteFullSave(chatHistory)\n if not chatHistory or #chatHistory <= 0 then\n return true\n end\n\n local checkedCount = 0\n\n for i = #chatHistory, 1, -1 do\n local text = chatHistory[i] and chatHistory[i].data\n\n if type(text) == \"string\" and trim(text) ~= \"\" then\n checkedCount = checkedCount + 1\n\n if text:find(\"<SaveFullData>\", 1, true) then\n return false\n end\n\n if checkedCount >= 30 then\n break\n end\n end\n end\n\n return true\n end\n\n local curPhase = getPhase(triggerId)\n\n -- ==========================================\n -- 변경 값 추적\n -- ==========================================\n local changedVars = {}\n\n -- ==========================================\n -- 턴 증가\n -- ==========================================\n local turnTarget = 0\n if curPhase == constPhase.event then\n turnTarget = 2\n elseif curPhase == constPhase.hScene_Event then\n turnTarget = 2\n end\n\n if turnTarget > 0 then\n local turn = tonumber(getDualFlag(triggerId, hrSessionStatus.varName, hrSessionStatus.turn, false)) or 0\n\n if turn < turnTarget then\n setDualFlag(triggerId, hrSessionStatus.varName, hrSessionStatus.turn, tostring(turn + 1), true)\n end\n end\n\n -- ==========================================\n -- 자유모드 전환\n -- ==========================================\n if curPhase == constPhase.free then\n local choiceFlag = tonumber(safeGet(triggerId, \"hr_Choice_Flag\", tostring(constChoice.none))) or constChoice.none\n local sexualTraitFlag = safeGet(triggerId, \"hr_SexualTrait_Flag\", constFlag.no)\n\n if choiceFlag == constChoice.choice_CreateRoute or choiceFlag == constChoice.choice_CreateHScene then\n setPhase(triggerId, constPhase.free)\n setChatVar(triggerId, \"hr_Route_Custom\", \"\")\n elseif prevPhase == constPhase.free and sexualTraitFlag == constFlag.yes then\n setPhase(triggerId, constPhase.free)\n end\n end\n\n -- ==========================================\n -- Next 값 적용\n -- ==========================================\n applyNextDualFlagToCurrent(triggerId, hrContext.varName)\n\n if applyNextDualFlagToCurrent(triggerId, hrSessionStatus.varName) then\n changedVars[hrSessionStatus.varName] = safeGet(triggerId, hrSessionStatus.varName, \"\")\n end\n\n -- ==========================================\n -- CleanMsg\n -- ==========================================\n local chatHistory = getFullChat(triggerId)\n\n isFullSave = shouldWriteFullSave(chatHistory)\n\n if #chatHistory > 0 then\n local lastIndex = #chatHistory\n local lastMsg = chatHistory[lastIndex].data or \"\"\n local cleanMsg = lastMsg\n\n -- ==========================================\n -- Thought 블록은 파싱 대상에서 제외\n -- ==========================================\n local parseMsg, thoughtBlocks = maskThoughtBlocksForParsing(cleanMsg)\n\n local currentDateText = extractLastBracketDateText(parseMsg)\n\n if currentDateText ~= \"\" then\n local lastDateText = safeGet(triggerId, \"hr_LastDate\", \"\")\n\n ensureLastPeriod(triggerId, currentDateText, changedVars)\n\n if lastDateText ~= currentDateText then\n if lastDateText == \"\" then\n isFullSave = true\n end\n\n setTrackedChatVar(triggerId, changedVars, \"hr_LastDate\", currentDateText)\n end\n end\n\n -- ==========================================\n -- 이벤트 요약\n -- ==========================================\n do\n local eventSummaryLogs = {}\n parseMsg, eventSummaryLogs = parseEventSummaryRecordBlocks(triggerId, parseMsg, changedVars)\n\n for _, logText in ipairs(eventSummaryLogs or {}) do\n addTempLog(logText)\n end\n end\n\n -- ==========================================\n -- 상태창\n -- ==========================================\n parseMsg = parseStatusWindowHeroineFilter(triggerId, parseMsg)\n\n -- ==========================================\n -- 초기 설정\n -- ==========================================\n parseMsg = parseHeroineInitialSettingBlocks(triggerId, parseMsg)\n\n -- ==========================================\n -- 흥분 체크\n -- ==========================================\n do\n local arousalLogs = {}\n parseMsg, arousalLogs = parsePhysiologicalArousalStateBlocks(triggerId, parseMsg)\n\n for _, logText in ipairs(arousalLogs or {}) do\n addTempLog(logText)\n end\n end\n\n -- ==========================================\n -- 첫경험\n -- ==========================================\n parseMsg = parseFirstExperienceCheckBlocks(triggerId, parseMsg, changedVars)\n\n do\n local firstExperienceLogs = {}\n parseMsg, firstExperienceLogs = parseFirstExperienceRecordBlocks(triggerId, parseMsg, changedVars)\n\n for _, logText in ipairs(firstExperienceLogs or {}) do\n addTempLog(logText)\n end\n end\n\n -- ==========================================\n -- 임신\n -- ==========================================\n do\n local pregnancyEndLogs = {}\n parseMsg, pregnancyEndLogs = parsePregnancyEndRecordBlocks(triggerId, parseMsg, changedVars)\n for _, logText in ipairs(pregnancyEndLogs or {}) do\n addTempLog(logText)\n end\n\n local pregnancyRecordLogs = {}\n parseMsg, pregnancyRecordLogs = parsePregnancyStartRecordBlocks(triggerId, parseMsg, changedVars)\n for _, logText in ipairs(pregnancyRecordLogs or {}) do\n addTempLog(logText)\n end\n\n local contraceptionLogs = {}\n parseMsg, contraceptionLogs = parseEmergencyContraceptionRecordBlocks(triggerId, parseMsg, changedVars)\n for _, logText in ipairs(contraceptionLogs or {}) do\n addTempLog(logText)\n end\n\n processPregnancyStartChecks(triggerId, changedVars)\n\n parseMsg = parseMsg:gsub(\"<Conception Risk Recorded>%s*[%s%S]-%s*</Conception Risk Recorded>\", \"\")\n\n local conceptionRiskLogs = {}\n local conceptionRiskRecordedText = \"\"\n parseMsg, conceptionRiskLogs, conceptionRiskRecordedText = parseConceptionRiskRecordBlocks(triggerId, parseMsg, changedVars)\n for _, logText in ipairs(conceptionRiskLogs or {}) do\n addTempLog(logText)\n end\n addLastText(conceptionRiskRecordedText)\n end\n\n do\n local parsedChoiceTexts = {}\n \n parseMsg, parsedChoiceTexts = parseHSceneChoiceTexts(triggerId, parseMsg)\n\n for _, choiceText in ipairs(parsedChoiceTexts) do\n addChoiceText(choiceText)\n end\n end\n\n -- ==========================================\n -- 성적 특성\n -- ==========================================\n do\n local sexualTraitLogs = {}\n parseMsg, sexualTraitLogs = parseSexualTraitDevelopmentBlocks(triggerId, parseMsg, changedVars)\n\n for _, logText in ipairs(sexualTraitLogs or {}) do\n addTempLog(logText)\n end\n end\n\n -- ==========================================\n -- 지속 효과\n -- ==========================================\n do\n local sexualEffectLogs = {}\n parseMsg, sexualEffectLogs = parseImposedSexualEffectBlocks(triggerId, parseMsg, changedVars)\n\n for _, logText in ipairs(sexualEffectLogs or {}) do\n addTempLog(logText)\n end\n end\n\n -- ==========================================\n -- 선택지 생성\n -- ==========================================\n if curPhase == constPhase.free then\n local parsedChoiceTexts = {}\n\n parseMsg, parsedChoiceTexts = parseRouteChoiceTexts(triggerId, parseMsg)\n\n for _, choiceText in ipairs(parsedChoiceTexts or {}) do\n addChoiceText(choiceText)\n end\n\n -- ==========================================\n -- 자위\n -- ==========================================\n elseif curPhase == constPhase.masturbation then\n local parsedChoiceTexts = {}\n\n parseMsg, parsedChoiceTexts = parseMasturbationChoiceTexts(triggerId, parseMsg)\n\n for _, choiceText in ipairs(parsedChoiceTexts) do\n addChoiceText(choiceText)\n end\n -- ==========================================\n -- H씬\n -- ==========================================\n elseif curPhase == constPhase.hScene_Event then\n local parsedChoiceTexts = {}\n parseMsg, parsedChoiceTexts = parseViolationChoiceTexts(triggerId, parseMsg)\n\n for _, choiceText in ipairs(parsedChoiceTexts) do\n addChoiceText(choiceText)\n end\n\n -- ==========================================\n -- 상황 종료\n -- ==========================================\n elseif curPhase == constPhase.event_Resolution\n or curPhase == constPhase.event_Incapacitated \n or curPhase == constPhase.masturbation_End\n or curPhase == constPhase.hScene_End then\n\n if curPhase == constPhase.hScene_End then\n local parsedChoiceTexts = {}\n\n parseMsg, parsedChoiceTexts = parseHSceneEndTexts(triggerId, parseMsg)\n\n for _, choiceText in ipairs(parsedChoiceTexts) do\n addChoiceText(choiceText)\n end\n end\n\n resetSession(triggerId)\n\n setPhase(triggerId, constPhase.free)\n applyNextDualFlagToCurrent(triggerId, hrContext.varName)\n\n setChatVar(triggerId, \"hr_SessionSave_Flag\", constFlag.yes)\n\n -- ==========================================\n -- 장면 이벤트 선택지\n -- ==========================================\n else\n parseMsg = parseEventObjectiveBlock(triggerId, parseMsg, changedVars)\n\n local parsedChoiceTexts = {}\n parseMsg, parsedChoiceTexts = parseEventDevelopChoiceTexts(triggerId, parseMsg, curPhase)\n\n for _, choiceText in ipairs(parsedChoiceTexts or {}) do\n addChoiceText(choiceText)\n end\n end\n\n -- 활성화 캐릭터 성적특성 괄호 표기 제거\n parseMsg = stripActiveSexualTraitParentheses(triggerId, parseMsg)\n\n if isFullSave then\n saveText = buildFullSave(triggerId)\n else\n saveText = buildSessionSave(triggerId, changedVars)\n end\n\n setChatVar(triggerId, \"hr_TrackValue\", \"\")\n setChatVar(triggerId, \"hr_SessionSave_Flag\", constFlag.no)\n setChatVar(triggerId, \"hr_StartSetting\", constFlag.no)\n\n cleanMsg = restoreThoughtBlocksAfterParsing(parseMsg, thoughtBlocks)\n cleanMsg = cleanMsg .. buildExtraOutput() .. \"\\n◐◎◑\"\n if cleanMsg ~= lastMsg then\n setChat(triggerId, lastIndex - 1, cleanMsg)\n end\n end\n\n generateHTML(triggerId)\n\nend)\n\n-- ==========================================\n-- 버튼 선택\n-- ==========================================\nonButtonClick = async(function(triggerId, c)\n\n local parts = {}\n\n for m in (c .. \"_\"):gmatch(\"(.-)_\") do\n table.insert(parts, sanitizeChoiceFieldValue(m))\n end\n\n -- choice가 아니면 종료\n if parts[1] ~= \"choice\" then\n return\n end\n\n local choiceType = parts[2]\n\n local function appendChoiceInput(text)\n text = tostring(text or \"\")\n\n if trim(text) == \"\" then\n return\n end\n\n local cl = getChatLength(triggerId)\n\n if cl > 0 then\n local last = getChat(triggerId, cl - 1)\n\n if last and last.role == \"user\" and type(last.data) == \"string\" then\n local oldText = tostring(last.data or \"\")\n\n if trim(oldText) == \"\" then\n setChat(triggerId, cl - 1, text)\n else\n setChat(triggerId, cl - 1, oldText .. \"\\n\" .. text)\n end\n\n return\n end\n end\n\n addChat(triggerId, \"user\", text)\n end\n\n local function parseChoiceFields(entry)\n local raw = trim(tostring(entry or \"\"))\n raw = raw:gsub(\"^%[\", \"\"):gsub(\"%]$\", \"\")\n\n local fields = {}\n\n for part in raw:gmatch(\"([^|]+)\") do\n local key, value = part:match(\"^%s*(.-)%s*:%s*(.-)%s*$\")\n\n if key and key ~= \"\" then\n fields[string.lower(trim(key))] = trim(value or \"\")\n end\n end\n\n return fields, raw\n end\n\n local function getLastUserText()\n local cl = getChatLength(triggerId)\n\n if cl <= 0 then\n return \"\"\n end\n\n local last = getChat(triggerId, cl - 1)\n\n if last and last.role == \"user\" and type(last.data) == \"string\" then\n return tostring(last.data or \"\")\n end\n\n return \"\"\n end\n\n -- ==========================================\n -- 루트 선택\n -- ==========================================\n if choiceType == \"selectRoute\" then\n local routeMain = sanitizeChoiceFieldValue(parts[3] or \"\")\n local mode = sanitizeChoiceFieldValue(parts[4] or \"\")\n local icon = sanitizeChoiceFieldValue(parts[5] or \"\")\n local time = sanitizeChoiceFieldValue(parts[6] or \"\")\n local location = sanitizeChoiceFieldValue(parts[7] or \"\")\n local setup = sanitizeChoiceFieldValue(parts[8] or \"\")\n local phaseValue = tonumber(sanitizeChoiceFieldValue(parts[9] or \"\")) or constPhase.none\n\n if phaseValue == constPhase.event_Incapacitated then\n setChatVar(triggerId, \"hr_Event_Main\", routeMain)\n end\n\n do\n local cl = getChatLength(triggerId)\n\n if cl > 0 then\n local lastIndex = cl - 1\n local last = getChat(triggerId, lastIndex)\n\n if last and last.role == \"user\" and type(last.data) == \"string\" then\n setChat(triggerId, lastIndex, \"\")\n end\n end\n end\n\n local inputText =\n \"[Route Select: \" .. routeMain ..\n \" | Mode: \" .. mode ..\n \" | Icon: \" .. icon ..\n \" | Time: \" .. time ..\n \" | Location: \" .. location ..\n \" | Setup: \" .. setup ..\n \"]\"\n\n setPhase(triggerId, phaseValue, true, routeMain)\n\n appendChoiceInput(inputText)\n return\n end\n\n -- ==========================================\n -- 자위 선택\n -- ==========================================\n if choiceType == \"selectMasturbation\" then\n local action = sanitizeChoiceFieldValue(parts[3] or \"\")\n local icon = sanitizeChoiceFieldValue(parts[4] or \"\")\n local focus = sanitizeChoiceFieldValue(parts[5] or \"\")\n\n local heroineName = safeGet(triggerId, \"hr_Event_Main\", \"\")\n local lastUserText = getLastUserText()\n\n local selectedList = {}\n local selectedActionMap = {}\n\n -- 이미 입력된 Masturbation Select 복원\n -- Masturbation End Select는 복원하지 않음.\n for entry in lastUserText:gmatch(\"%b[]\") do\n local fields = parseChoiceFields(entry)\n\n local selectedAction = sanitizeChoiceFieldValue(fields[\"masturbation select\"] or \"\")\n local selectedIcon = sanitizeChoiceFieldValue(fields[\"icon\"] or \"\")\n local selectedFocus = sanitizeChoiceFieldValue(fields[\"focus\"] or \"\")\n\n if selectedAction ~= \"\" and selectedIcon ~= \"\" and selectedFocus ~= \"\" then\n table.insert(selectedList, {\n action = selectedAction,\n icon = selectedIcon,\n focus = selectedFocus,\n })\n\n selectedActionMap[selectedAction] = true\n end\n end\n\n -- 이미 선택된 액션이면 막기\n if selectedActionMap[action] == true then\n alertError(triggerId, \"❌ 이미 선택한 행동입니다.\")\n return\n end\n\n -- 최대 3개까지만 선택 가능\n if #selectedList >= 3 then\n alertError(triggerId, \"❌ 자위 행동은 최대 3개까지만 선택할 수 있습니다.\")\n return\n end\n\n table.insert(selectedList, {\n action = action,\n icon = icon,\n focus = focus,\n })\n\n local appendLines = {}\n\n for _, selected in ipairs(selectedList) do\n table.insert(\n appendLines,\n \"[Masturbation Select: \" .. selected.action ..\n \" | Icon: \" .. selected.icon ..\n \" | Focus: \" .. selected.focus ..\n \"]\"\n )\n end\n\n local eventMessage =\n heroineName .. \" must continue masturbating; the scene must not end, stop, resolve, or move into aftermath.\\n\" ..\n \"The act must remain solo masturbation, even if someone else is aware of what she is doing or watching her; no one else must be allowed to touch her, interfere with her, attack her, or participate in it.\\n\" ..\n \"There must be no immediate physical threat, attack, or realistic chance of being attacked.\"\n\n setPhase(triggerId, constPhase.masturbation)\n setChatVar(triggerId, \"hr_Event_Message\", eventMessage)\n\n table.insert(appendLines, \"[HR Hidden Message]\")\n\n pushChoiceInput(triggerId, table.concat(appendLines, \"\\n\"))\n return\n end\n\n -- ==========================================\n -- 자위 중단\n -- ==========================================\n if choiceType == \"selectMasturbationEnd\" then\n local endText = sanitizeChoiceFieldValue(parts[3] or \"\")\n local endIcon = sanitizeChoiceFieldValue(parts[4] or \"\")\n\n local heroineName = safeGet(triggerId, \"hr_Event_Main\", \"\")\n local lastUserText = getLastUserText()\n\n -- 이미 Masturbation End Select가 있으면 추가하지 않음\n if lastUserText:find(\"[Masturbation End Select:\", 1, true) then\n return\n end\n\n local hasMasturbationSelect = false\n\n -- Masturbation Select가 하나라도 있어야 종료 가능\n for entry in lastUserText:gmatch(\"%b[]\") do\n local fields = parseChoiceFields(entry)\n\n local selectedAction = sanitizeChoiceFieldValue(fields[\"masturbation select\"] or \"\")\n local selectedIcon = sanitizeChoiceFieldValue(fields[\"icon\"] or \"\")\n local selectedFocus = sanitizeChoiceFieldValue(fields[\"focus\"] or \"\")\n\n if selectedAction ~= \"\" and selectedIcon ~= \"\" and selectedFocus ~= \"\" then\n hasMasturbationSelect = true\n break\n end\n end\n\n if hasMasturbationSelect ~= true then\n alertError(triggerId, \"❌ 먼저 자위 행동을 하나 이상 선택해야 합니다.\")\n return\n end\n\n local eventMessage =\n heroineName .. \" brings the masturbation to its end after climax, in a way that fits the current situation and her condition.\\n\" ..\n \"Afterward, describe her gradually calming down as her arousal begins to ease, while the lingering afterglow of climax remains present throughout this response, leaving her softened, dazed, or emotionally affected rather than suddenly fully calm, cold, or rational.\\n\" ..\n \"In this response, there must be no immediate physical threat, attack, or realistic chance of being attacked.\"\n\n setPhase(triggerId, constPhase.masturbation_End)\n setChatVar(triggerId, \"hr_Event_Message\", eventMessage)\n\n appendChoiceInput(\"[Masturbation End Select: \" .. endText .. \" | Icon: \" .. endIcon .. \"]\")\n return\n end\n\n -- ==========================================\n -- H씬 루트 선택\n -- ==========================================\n if choiceType == \"selectHScene\" then\n local heroineName = sanitizeChoiceFieldValue(parts[3] or \"\")\n local title = sanitizeChoiceFieldValue(parts[4] or \"\")\n local icon = sanitizeChoiceFieldValue(parts[5] or \"\")\n local location = sanitizeChoiceFieldValue(parts[6] or \"\")\n local setup = sanitizeChoiceFieldValue(parts[7] or \"\")\n\n setChatVar(triggerId, \"hr_Event_Main\", heroineName)\n\n setDualFlag(triggerId, hrSessionStatus.varName, hrSessionStatus.title, title, true)\n\n local eventMessage =\n \"Describe naturally how the situation develops from the previous scene outcome, showing how \" .. heroineName .. \" ends up in that location and situation.\\n\" ..\n \"Once there, depict the location, her condition, and the established setup where she is in danger of sexual violation, but stop before any sexual violation begins or is depicted.\\n\" ..\n \"Near the end of the response, if the opposing side has not already revealed or prepared the means it will use, it clearly reveals, readies, or activates whatever means it will use for the intended sexual act involving the heroine that is about to follow.\"\n\n local inputLines = {}\n\n table.insert(inputLines,\n \"[H Scene Select: \" .. heroineName ..\n \" | Title: \" .. title ..\n \" | Icon: \" .. icon ..\n \" | Location: \" .. location ..\n \" | Setup: \" .. setup ..\n \"]\"\n )\n\n table.insert(inputLines, \"[HR Hidden Message]\")\n\n setChatVar(triggerId, \"hr_Event_Message\", eventMessage)\n setPhase(triggerId, constPhase.hScene_Event, true)\n\n pushChoiceInput(triggerId, table.concat(inputLines, \"\\n\"))\n return\n end\n\n -- ==========================================\n -- 운명 선택\n -- ==========================================\n if choiceType == \"selectFate\" then\n local heroineName = sanitizeChoiceFieldValue(parts[3] or \"\")\n local situation = sanitizeChoiceFieldValue(parts[4] or \"\")\n local icon = sanitizeChoiceFieldValue(parts[5] or \"\")\n local time = sanitizeChoiceFieldValue(parts[6] or \"\")\n local location = sanitizeChoiceFieldValue(parts[7] or \"\")\n\n setChatVar(triggerId, \"hr_Event_Main\", heroineName)\n\n local marker =\n \"[Fate Select: \" .. heroineName ..\n \" | Situation: \" .. situation ..\n \" | Icon: \" .. icon ..\n \" | Time: \" .. time ..\n \" | Location: \" .. location ..\n \"]\"\n\n local cl = getChatLength(triggerId)\n local last = nil\n\n if cl > 0 then\n last = getChat(triggerId, cl - 1)\n end\n\n if last and last.role == \"user\" and type(last.data) == \"string\" then\n local lastText = tostring(last.data or \"\")\n\n -- 기존 Fate Select 제거\n lastText = lastText:gsub(\"[ \\t]*%[Fate Select:%s*[^%]]-%][ \\t]*\\r?\\n?\", \"\")\n lastText = trim(lastText)\n\n -- 새 Fate Select를 맨 위에 삽입\n if lastText ~= \"\" then\n setChat(triggerId, cl - 1, marker .. \"\\n\" .. lastText)\n else\n setChat(triggerId, cl - 1, marker)\n end\n else\n addChat(triggerId, \"user\", marker)\n end\n\n return\n end\n\n -- ==========================================\n -- H씬 액션 선택\n -- ==========================================\n if choiceType == \"selectViolationChoice\" then\n local REQUIRED_VIOLATION_COUNT = 2\n local heroineName = safeGet(triggerId, \"hr_Event_Main\", \"\")\n\n local function getLatestCharViolationChoiceBlock()\n local history = getFullChat(triggerId)\n\n if not history or #history == 0 then\n return \"\"\n end\n\n for i = #history, 1, -1 do\n local msg = history[i]\n\n if msg and msg.role == \"char\" and type(msg.data) == \"string\" then\n local latestChoice = \"\"\n\n for choiceBlock in tostring(msg.data):gmatch(\"<Choice>%s*([%s%S]-)%s*</Choice>\") do\n if choiceBlock:find(\"[Violation Threat:\", 1, true) or choiceBlock:find(\"[Heroine Violation Choice:\", 1, true) then\n latestChoice = choiceBlock\n end\n end\n\n if latestChoice ~= \"\" then\n return latestChoice\n end\n end\n end\n\n return \"\"\n end\n\n local function buildViolationChoiceData(order, action, icon, resultingSexualAct, resultingFocus, orderEmoji)\n order = tonumber(order) or 0\n orderEmoji = trim(tostring(orderEmoji or \"\"))\n\n if orderEmoji == \"\" then\n orderEmoji = getOrderEmoji(order)\n end\n\n return {\n order = order,\n orderEmoji = orderEmoji,\n action = trim(tostring(action or \"\")),\n icon = trim(tostring(icon or \"\")),\n resultingSexualAct = trim(tostring(resultingSexualAct or \"\")),\n resultingFocus = trim(tostring(resultingFocus or \"\")),\n }\n end\n\n local function buildViolationChoiceButtonPayload(data)\n return tostring(data.action or \"\") .. \"_\" .. tostring(data.icon or \"\") .. \"_\" .. tostring(data.resultingSexualAct or \"\") .. \"_\" .. tostring(data.resultingFocus or \"\")\n end\n\n local function buildViolationThreatSelectLine(threat)\n return \"[Violation Threat Select: \" .. tostring(threat.order)\n .. \" | Actor: \" .. tostring(threat.actor or \"\")\n .. \" | Icon: \" .. tostring(threat.icon or \"\")\n .. \" | Intent: \" .. tostring(threat.intent or \"\")\n .. \"]\"\n end\n\n local function buildHeroineViolationSelectLine(choice, isPending)\n local line = \"[Heroine Violation Select: \" .. tostring(choice.order)\n .. \" | Icon: \" .. tostring(choice.icon or \"\")\n .. \" | Action: \" .. tostring(choice.action or \"\")\n .. \" | Result: \" .. tostring(choice.resultingSexualAct or \"\")\n\n if isPending then\n line = line .. \" | Pending\"\n end\n\n return line .. \"]\"\n end\n\n local function countViolationSelections(selectedByOrder)\n local count = 0\n\n for order = 1, REQUIRED_VIOLATION_COUNT do\n if selectedByOrder[order] then\n count = count + 1\n end\n end\n\n return count\n end\n\n local function buildActFocusText(sexualAct, focus)\n sexualAct = trim(tostring(sexualAct or \"\"))\n focus = trim(tostring(focus or \"\"))\n\n if sexualAct ~= \"\" and focus ~= \"\" then\n return sexualAct .. \"(\" .. focus .. \")\"\n end\n\n if sexualAct ~= \"\" then\n return sexualAct\n end\n\n if focus ~= \"\" then\n return \"(\" .. focus .. \")\"\n end\n\n return \"\"\n end\n\n local function buildViolationResultLine(threat, choice)\n local intentSexualAct = trim(tostring(threat.intentSexualAct or \"\"))\n local intentFocus = trim(tostring(threat.intentFocus or \"\"))\n local resultingSexualAct = trim(tostring(choice.resultingSexualAct or \"\"))\n local resultingFocus = trim(tostring(choice.resultingFocus or \"\"))\n\n local beforeText = buildActFocusText(intentSexualAct, intentFocus)\n local afterText = buildActFocusText(resultingSexualAct, resultingFocus)\n local actor = trim(tostring(threat.actor or \"\"))\n\n if actor == \"\" then\n actor = \"Actor\"\n end\n\n if intentSexualAct == resultingSexualAct and intentFocus == resultingFocus then\n return \"Outcome: \" .. afterText\n end\n\n return \"Outcome: \" .. actor .. \"'s intended \" .. beforeText .. \" -> actual \" .. afterText\n end\n\n local function normalizeCompareText(value)\n local s = trim(tostring(value or \"\"))\n s = s:gsub(\"%s+\", \" \")\n return string.lower(s)\n end\n\n local function buildTransitionLine(label, beforeText, afterText, beforePrefix)\n beforeText = trim(tostring(beforeText or \"\"))\n afterText = trim(tostring(afterText or \"\"))\n beforePrefix = trim(tostring(beforePrefix or \"\"))\n\n if beforeText == \"\" and afterText == \"\" then\n return label .. \":\"\n end\n\n if afterText == \"\" then\n return label .. \": \" .. beforeText\n end\n\n if beforeText == \"\" or normalizeCompareText(beforeText) == normalizeCompareText(afterText) then\n return label .. \": \" .. afterText\n end\n\n if beforePrefix ~= \"\" then\n beforeText = beforePrefix .. \" \" .. beforeText\n end\n\n return label .. \": \" .. beforeText .. \" → \" .. afterText\n end\n\n local function findViolationChoiceByPayload(choicesByOrder, order, payload)\n order = tonumber(order) or 0\n payload = tostring(payload or \"\")\n\n for _, data in ipairs(choicesByOrder[order] or {}) do\n if buildViolationChoiceButtonPayload(data) == payload then\n return data\n end\n end\n\n return nil\n end\n\n local function findViolationChoiceByVisibleText(choicesByOrder, order, action, icon)\n order = tonumber(order) or 0\n action = trim(tostring(action or \"\"))\n icon = trim(tostring(icon or \"\"))\n\n for _, data in ipairs(choicesByOrder[order] or {}) do\n if data.action == action and data.icon == icon then\n return data\n end\n end\n\n return nil\n end\n\n do\n local lastText = getLastUserText()\n\n if lastText:find(\"[HR Hidden Message]\", 1, true) ~= nil or lastText:find(\"[HR Event Action]\", 1, true) ~= nil then\n return\n end\n end\n\n local rawChoicePayload = tostring(c or \"\"):match(\"^choice_selectViolationChoice_(.*)$\") or \"\"\n local selectedOrderText, selectedPayload = rawChoicePayload:match(\"^(.-)_(.*)$\")\n local selectedOrder = extractInt(selectedOrderText or \"\")\n\n selectedPayload = tostring(selectedPayload or \"\")\n\n if selectedOrder < 1 or selectedOrder > REQUIRED_VIOLATION_COUNT or selectedPayload == \"\" then\n alertError(triggerId, \"❌ 선택 데이터를 읽을 수 없습니다.\")\n return\n end\n\n local choiceBlock = getLatestCharViolationChoiceBlock()\n\n if choiceBlock == \"\" then\n alertError(triggerId, \"❌ 선택지를 찾을 수 없습니다.\")\n return\n end\n\n local threatsByOrder = {}\n local choicesByOrder = { [1] = {}, [2] = {} }\n\n for entry in tostring(choiceBlock):gmatch(\"%b[]\") do\n local fields = parseChoiceFields(entry)\n\n do\n local order = extractInt(fields[\"violation threat\"] or \"\")\n local orderEmoji = trim(fields[\"order emoji\"] or \"\")\n local actor = trim(fields[\"actor\"] or \"\")\n local icon = trim(fields[\"icon\"] or \"\")\n local intent = trim(fields[\"intent\"] or \"\")\n local intentSexualAct = trim(fields[\"intent sexual act\"] or \"\")\n local intentFocus = trim(fields[\"intent focus\"] or \"\")\n\n if order >= 1 and order <= REQUIRED_VIOLATION_COUNT and threatsByOrder[order] == nil and actor ~= \"\" and icon ~= \"\" and intent ~= \"\" and intentSexualAct ~= \"\" and intentFocus ~= \"\" then\n threatsByOrder[order] = {\n order = order,\n orderEmoji = orderEmoji ~= \"\" and orderEmoji or getOrderEmoji(order),\n actor = actor,\n icon = icon,\n intent = intent,\n intentSexualAct = intentSexualAct,\n intentFocus = intentFocus,\n }\n end\n end\n\n do\n local order = extractInt(fields[\"heroine violation choice\"] or \"\")\n local orderEmoji = trim(fields[\"order emoji\"] or \"\")\n local action = trim(fields[\"action\"] or \"\")\n local icon = trim(fields[\"icon\"] or \"\")\n local resultingSexualAct = trim(fields[\"resulting sexual act\"] or \"\")\n local resultingFocus = trim(fields[\"resulting focus\"] or \"\")\n\n if order >= 1 and order <= REQUIRED_VIOLATION_COUNT and action ~= \"\" and icon ~= \"\" and resultingSexualAct ~= \"\" and resultingFocus ~= \"\" then\n table.insert(choicesByOrder[order], buildViolationChoiceData(order, action, icon, resultingSexualAct, resultingFocus, orderEmoji))\n end\n end\n end\n\n local selectedChoice = findViolationChoiceByPayload(choicesByOrder, selectedOrder, selectedPayload)\n\n if not selectedChoice and #parts >= 7 then\n local selectedAction = sanitizeChoiceFieldValue(parts[4] or \"\")\n local selectedIcon = sanitizeChoiceFieldValue(parts[5] or \"\")\n local selectedResultingSexualAct = sanitizeChoiceFieldValue(parts[6] or \"\")\n local focusParts = {}\n\n for i = 7, #parts do\n table.insert(focusParts, parts[i])\n end\n\n local selectedResultingFocus = sanitizeChoiceFieldValue(table.concat(focusParts, \"_\"))\n\n for _, data in ipairs(choicesByOrder[selectedOrder] or {}) do\n if data.action == selectedAction and data.icon == selectedIcon and data.resultingSexualAct == selectedResultingSexualAct and data.resultingFocus == selectedResultingFocus then\n selectedChoice = data\n break\n end\n end\n end\n\n if not selectedChoice then\n alertError(triggerId, \"❌ 해당 선택지를 찾을 수 없습니다.\")\n return\n end\n\n -- ==========================================\n -- Pending 선택 복원\n -- Pending에는 Resulting Sexual Act / Focus를 저장하지 않는다.\n -- Order + Action + Icon으로 최신 Choice 블록에서 다시 찾는다.\n -- ==========================================\n local selectedByOrder = {}\n\n for entry in getLastUserText():gmatch(\"%b[]\") do\n local fields = parseChoiceFields(entry)\n local order = extractInt(fields[\"heroine violation select\"] or \"\")\n\n if order >= 1 and order <= REQUIRED_VIOLATION_COUNT then\n local action = trim(fields[\"action\"] or \"\")\n local icon = trim(fields[\"icon\"] or \"\")\n\n if action ~= \"\" and icon ~= \"\" then\n local restoredChoice = findViolationChoiceByVisibleText(choicesByOrder, order, action, icon)\n\n if restoredChoice then\n selectedByOrder[order] = restoredChoice\n end\n end\n end\n end\n\n -- ==========================================\n -- 첫 번째 선택일 때만 초기화\n -- ==========================================\n if countViolationSelections(selectedByOrder) <= 0 then\n setChatVar(triggerId, \"hr_Event_Action\", \"\")\n setChatVar(triggerId, \"hr_Event_Message\", \"\")\n\n clearNextDualFlag(triggerId, hrContext.varName)\n\n local curPhase = getPhase(triggerId)\n setPhase(triggerId, curPhase)\n end\n\n if selectedByOrder[selectedOrder] then\n alertError(triggerId, \"❌ 같은 번호의 선택지는 하나만 선택할 수 있습니다.\")\n return\n end\n\n selectedByOrder[selectedOrder] = selectedChoice\n\n -- ==========================================\n -- 첫 번째 선택: Pending 저장\n -- ==========================================\n if countViolationSelections(selectedByOrder) < REQUIRED_VIOLATION_COUNT then\n local waitingLines = {}\n\n for order = 1, REQUIRED_VIOLATION_COUNT do\n if selectedByOrder[order] then\n table.insert(waitingLines, buildHeroineViolationSelectLine(selectedByOrder[order], true))\n end\n end\n\n pushChoiceInput(triggerId, table.concat(waitingLines, \"\\n\"))\n return\n end\n\n -- ==========================================\n -- 두 번째 선택: 오더 1, 2 둘 다 있는지 확인\n -- ==========================================\n for order = 1, REQUIRED_VIOLATION_COUNT do\n if not selectedByOrder[order] then\n alertError(triggerId, \"❌ 오더 \" .. tostring(order) .. \" 선택이 필요합니다.\")\n return\n end\n\n if not threatsByOrder[order] then\n alertError(triggerId, \"❌ 오더 \" .. tostring(order) .. \" 적 위협 정보를 찾을 수 없습니다.\")\n return\n end\n end\n\n -- ==========================================\n -- 입력 / hr_Event_Action 구성\n -- pushChoiceInput에는 전투처럼 전부 [ ... ] 라인만 넣는다.\n -- Resulting Sexual Act / Focus는 hr_Event_Action에만 사용한다.\n -- ==========================================\n local appendLines = {}\n local eventActionLines = {}\n local hasChangedOutcome = false\n\n for order = 1, REQUIRED_VIOLATION_COUNT do\n local threat = threatsByOrder[order]\n local choice = selectedByOrder[order]\n\n table.insert(appendLines, buildViolationThreatSelectLine(threat))\n table.insert(appendLines, buildHeroineViolationSelectLine(choice, false))\n\n table.insert(eventActionLines, \"*\" .. threat.actor .. \": \" .. threat.intent .. \"*\")\n table.insert(eventActionLines, \"*\" .. heroineName .. \": \" .. choice.action .. \"*\")\n table.insert(eventActionLines, buildViolationResultLine(threat, choice))\n\n if trim(tostring(threat.intentSexualAct or \"\")) ~= trim(tostring(choice.resultingSexualAct or \"\"))\n or trim(tostring(threat.intentFocus or \"\")) ~= trim(tostring(choice.resultingFocus or \"\"))\n then\n hasChangedOutcome = true\n end\n\n if order < REQUIRED_VIOLATION_COUNT then\n table.insert(appendLines, \"[Blank Line]\")\n table.insert(eventActionLines, \"\")\n end\n end\n\n local eventMessage = \"\"\n\n if hasChangedOutcome then\n eventMessage = \n \"In Outcome, when intended and actual differ, the opposing side's intended result does not occur; instead, the actual result occurs because of the heroine's action.\\n\"\n .. \"Describe naturally how and why the heroine's action leads the opposing side to perform the actual sexual act instead of the one they originally intended.\\n\"\n end\n\n eventMessage = eventMessage\n .. \"Neither the heroine's own actions nor any outside intervention can end the situation, enable escape, or remove her from it.\\n\"\n .. \"During this response, the sexual violation must continue. It must not imply resolution, completion, or the opposing side's satisfaction.\"\n\n local turn = tonumber(getResolvedDualFlag(triggerId, hrSessionStatus.varName, hrSessionStatus.turn, 0)) or 0\n\n setChatVar(triggerId, \"hr_Event_Action\", table.concat(eventActionLines, \"\\n\"))\n setChatVar(triggerId, \"hr_Event_Message\", eventMessage)\n\n table.insert(appendLines, \"[HR Event Action]\")\n table.insert(appendLines, \"[HR Hidden Message]\")\n\n pushChoiceInput(triggerId, table.concat(appendLines, \"\\n\"))\n return\n end\n\n -- ==========================================\n -- 이벤트 전개\n -- ==========================================\n if choiceType == \"selectEventDevelop\" then\n local REQUIRED_EVENT_DEVELOP_COUNT = 2\n\n local cleanParts = {}\n\n for i = 1, #parts do\n cleanParts[i] = sanitizeChoiceFieldValue(parts[i])\n end\n\n local function getMainHeroineName()\n return trim(tostring(safeGet(triggerId, \"hr_Event_Main\", \"\") or \"\"))\n end\n\n local function getLatestCharEventDevelopChoiceBlock()\n local history = getFullChat(triggerId)\n\n if not history or #history == 0 then\n return \"\"\n end\n\n for i = #history, 1, -1 do\n local msg = history[i]\n\n if msg and msg.role == \"char\" and type(msg.data) == \"string\" then\n local latestChoice = \"\"\n\n for choiceBlock in tostring(msg.data):gmatch(\"<Choice>%s*([%s%S]-)%s*</Choice>\") do\n if choiceBlock:find(\"[Event Development:\", 1, true)\n or choiceBlock:find(\"[Heroine Side Choice:\", 1, true)\n or choiceBlock:find(\"[Heroine Side Action:\", 1, true)\n then\n latestChoice = choiceBlock\n end\n end\n\n if latestChoice ~= \"\" then\n return latestChoice\n end\n end\n end\n\n return \"\"\n end\n\n local function buildEventDevelopData(order, source, icon, development)\n return {\n order = tonumber(order) or 0,\n source = trim(tostring(source or \"\")),\n icon = trim(tostring(icon or \"\")),\n development = trim(tostring(development or \"\")),\n }\n end\n\n local function buildHeroineEventDevelopChoiceData(order, attempt, icon, outcomeType, result)\n return {\n order = tonumber(order) or 0,\n attempt = trim(tostring(attempt or \"\")),\n icon = trim(tostring(icon or \"\")),\n outcomeType = tonumber(outcomeType) or extractInt(outcomeType),\n result = trim(tostring(result or \"\")),\n }\n end\n\n local function buildEventDevelopChoiceButtonPayload(data)\n return sanitizeChoiceFieldValue(data.attempt)\n .. \"_\" .. sanitizeChoiceFieldValue(data.icon)\n .. \"_\" .. tostring(tonumber(data.outcomeType) or 0)\n .. \"_\" .. sanitizeChoiceFieldValue(data.result)\n end\n\n local function buildEventDevelopmentSelectLine(eventData)\n return \"[Event Development Select: \" .. tostring(eventData.order)\n .. \" | Source: \" .. tostring(eventData.source or \"\")\n .. \" | Icon: \" .. tostring(eventData.icon or \"\")\n .. \" | Development: \" .. tostring(eventData.development or \"\")\n .. \"]\"\n end\n\n local function buildHeroineEventDevelopSelectLine(choice, isPending)\n local line = \"[Heroine Side Select: \" .. tostring(choice.order)\n .. \" | Icon: \" .. tostring(choice.icon or \"\")\n .. \" | Attempt: \" .. tostring(choice.attempt or \"\")\n .. \" | Result: \" .. tostring(choice.result or \"\")\n\n if isPending then\n line = line .. \" | Pending\"\n end\n\n return line .. \"]\"\n end\n\n local function buildHeroineSideActionLine(data)\n return \"[Heroine Side Action: \" .. tostring(data.actor or \"\")\n .. \" | Action: \" .. tostring(data.action or \"\")\n .. \" | Icon: \" .. tostring(data.icon or \"\")\n .. \"]\"\n end\n\n local function countEventDevelopSelections(selectedByOrder)\n local count = 0\n\n for order = 1, REQUIRED_EVENT_DEVELOP_COUNT do\n if selectedByOrder[order] then\n count = count + 1\n end\n end\n\n return count\n end\n\n local function findEventDevelopChoiceByPayload(choicesByOrder, order, payload)\n order = tonumber(order) or 0\n payload = sanitizeChoiceFieldValue(payload)\n\n for _, data in ipairs(choicesByOrder[order] or {}) do\n if buildEventDevelopChoiceButtonPayload(data) == payload then\n return data\n end\n end\n\n return nil\n end\n\n local function findEventDevelopChoiceByVisibleText(choicesByOrder, order, attempt, icon)\n order = tonumber(order) or 0\n attempt = sanitizeChoiceFieldValue(attempt)\n icon = sanitizeChoiceFieldValue(icon)\n\n for _, data in ipairs(choicesByOrder[order] or {}) do\n if sanitizeChoiceFieldValue(data.attempt) == attempt\n and sanitizeChoiceFieldValue(data.icon) == icon\n then\n return data\n end\n end\n\n return nil\n end\n\n do\n local lastText = getLastUserText()\n\n if lastText:find(\"[HR Hidden Message]\", 1, true) ~= nil\n or lastText:find(\"[HR Event Action]\", 1, true) ~= nil\n then\n return\n end\n end\n\n local selectedOrder = extractInt(cleanParts[3] or \"\")\n\n local payloadParts = {}\n\n for i = 4, #cleanParts do\n table.insert(payloadParts, cleanParts[i])\n end\n\n local selectedPayload = table.concat(payloadParts, \"_\")\n\n if selectedOrder < 1 or selectedOrder > REQUIRED_EVENT_DEVELOP_COUNT or selectedPayload == \"\" then\n alertError(triggerId, \"❌ 선택 데이터를 읽을 수 없습니다.\")\n return\n end\n\n local choiceBlock = getLatestCharEventDevelopChoiceBlock()\n\n if choiceBlock == \"\" then\n alertError(triggerId, \"❌ 선택지를 찾을 수 없습니다.\")\n return\n end\n\n local eventByOrder = {}\n local choicesByOrder = { [1] = {}, [2] = {} }\n local sideActions = {}\n\n for entry in tostring(choiceBlock):gmatch(\"%b[]\") do\n local fields = parseChoiceFields(entry)\n\n do\n local order = extractInt(fields[\"event development\"] or \"\")\n local source = trim(fields[\"source\"] or \"\")\n local icon = trim(fields[\"icon\"] or \"\")\n local development = trim(fields[\"development\"] or \"\")\n\n if order >= 1 and order <= REQUIRED_EVENT_DEVELOP_COUNT\n and eventByOrder[order] == nil\n and source ~= \"\"\n and icon ~= \"\"\n and development ~= \"\"\n then\n eventByOrder[order] = buildEventDevelopData(order, source, icon, development)\n end\n end\n\n do\n local order = extractInt(fields[\"heroine side choice\"] or \"\")\n local attempt = trim(fields[\"attempt\"] or \"\")\n local icon = trim(fields[\"icon\"] or \"\")\n local outcomeType = extractInt(fields[\"outcome type\"] or \"\")\n local result = trim(fields[\"result\"] or \"\")\n\n if order >= 1 and order <= REQUIRED_EVENT_DEVELOP_COUNT\n and attempt ~= \"\"\n and icon ~= \"\"\n and outcomeType ~= constOutcome.none\n and result ~= \"\"\n then\n table.insert(\n choicesByOrder[order],\n buildHeroineEventDevelopChoiceData(order, attempt, icon, outcomeType, result)\n )\n end\n end\n\n do\n local actor = trim(fields[\"heroine side action\"] or \"\")\n local action = trim(fields[\"action\"] or \"\")\n local icon = trim(fields[\"icon\"] or \"\")\n\n if #sideActions < 2\n and actor ~= \"\"\n and action ~= \"\"\n and icon ~= \"\"\n then\n table.insert(sideActions, {\n actor = actor,\n action = action,\n icon = icon,\n })\n end\n end\n end\n\n local selectedChoice = findEventDevelopChoiceByPayload(choicesByOrder, selectedOrder, selectedPayload)\n\n if not selectedChoice then\n alertError(triggerId, \"❌ 해당 선택지를 찾을 수 없습니다.\")\n return\n end\n\n -- ==========================================\n -- Pending 선택 복원\n -- ==========================================\n local selectedByOrder = {}\n\n for entry in getLastUserText():gmatch(\"%b[]\") do\n local fields = parseChoiceFields(entry)\n local order = extractInt(fields[\"heroine side select\"] or \"\")\n\n if order >= 1 and order <= REQUIRED_EVENT_DEVELOP_COUNT then\n local attempt = trim(fields[\"attempt\"] or \"\")\n local icon = trim(fields[\"icon\"] or \"\")\n\n if attempt ~= \"\" and icon ~= \"\" then\n local restoredChoice = findEventDevelopChoiceByVisibleText(choicesByOrder, order, attempt, icon)\n\n if restoredChoice then\n selectedByOrder[order] = restoredChoice\n end\n end\n end\n end\n\n -- ==========================================\n -- 첫 번째 선택일 때만 초기화\n -- 현재 phase를 기본값으로 유지한다.\n -- ==========================================\n local curPhase = constPhase.none\n\n if countEventDevelopSelections(selectedByOrder) <= 0 then\n setChatVar(triggerId, \"hr_Event_Action\", \"\")\n setChatVar(triggerId, \"hr_Event_Message\", \"\")\n\n clearNextDualFlag(triggerId, hrSessionStatus.varName)\n clearNextDualFlag(triggerId, hrContext.varName)\n\n curPhase = getPhase(triggerId)\n setPhase(triggerId, curPhase)\n else\n curPhase = getPhase(triggerId)\n end\n\n if selectedByOrder[selectedOrder] then\n alertError(triggerId, \"❌ 같은 번호의 선택지는 하나만 선택할 수 있습니다.\")\n return\n end\n\n selectedByOrder[selectedOrder] = selectedChoice\n\n -- ==========================================\n -- 첫 번째 선택: Pending 저장\n -- ==========================================\n if countEventDevelopSelections(selectedByOrder) < REQUIRED_EVENT_DEVELOP_COUNT then\n local waitingLines = {}\n\n for order = 1, REQUIRED_EVENT_DEVELOP_COUNT do\n if selectedByOrder[order] then\n table.insert(waitingLines, buildHeroineEventDevelopSelectLine(selectedByOrder[order], true))\n end\n end\n\n pushChoiceInput(triggerId, table.concat(waitingLines, \"\\n\"))\n return\n end\n\n -- ==========================================\n -- 두 번째 선택: 오더 1, 2 둘 다 있는지 확인\n -- ==========================================\n for order = 1, REQUIRED_EVENT_DEVELOP_COUNT do\n if not selectedByOrder[order] then\n alertError(triggerId, \"❌ 오더 \" .. tostring(order) .. \" 선택이 필요합니다.\")\n return\n end\n\n if not eventByOrder[order] then\n alertError(triggerId, \"❌ 오더 \" .. tostring(order) .. \" 이벤트 정보를 찾을 수 없습니다.\")\n return\n end\n end\n\n -- ==========================================\n -- 세션 정보\n -- ==========================================\n local sessionStatusUpdates = {}\n local sexualAddCount = 0\n\n for order = 1, REQUIRED_EVENT_DEVELOP_COUNT do\n local choice = selectedByOrder[order]\n\n if tonumber(choice.outcomeType) == constOutcome.event_Molestation then\n sexualAddCount = sexualAddCount + 1\n end\n end\n\n if sexualAddCount > 0 then\n local sexualCount = tonumber(getResolvedDualFlag(triggerId, hrSessionStatus.varName, hrSessionStatus.sexualCount, 0)) or 0\n\n table.insert(sessionStatusUpdates, {\n position = hrSessionStatus.sexualCount,\n value = tostring(sexualCount + sexualAddCount),\n isNext = true,\n })\n end\n\n if #sessionStatusUpdates > 0 then\n batchSetDualFlags(triggerId, hrSessionStatus.varName, sessionStatusUpdates)\n end\n\n -- ==========================================\n -- 최종 페이즈 판정\n -- Resolution / Incapacitated는 Order 2에서만 판정한다.\n -- ==========================================\n local resultPhase = constPhase.none\n local finalOutcomeType = tonumber(selectedByOrder[2].outcomeType) or 0\n\n if finalOutcomeType == constOutcome.event_Resolution or finalOutcomeType == constOutcome.event_Closure then\n resultPhase = constPhase.event_Resolution\n elseif finalOutcomeType == constOutcome.event_Incapacitated then\n resultPhase = constPhase.event_Incapacitated\n end\n\n if resultPhase ~= constPhase.none then\n setPhase(triggerId, resultPhase)\n end\n \n -- ==========================================\n -- 입력 / hr_Event_Action / hr_Event_Message 구성\n -- pushChoiceInput에는 결과를 노출하지 않는다.\n -- Outcome Type은 phase 판정에만 사용한다.\n -- Result는 hr_Event_Action에만 사용한다.\n -- ==========================================\n local heroineName = getMainHeroineName()\n\n local appendLines = {}\n local eventActionLines = {}\n\n for order = 1, REQUIRED_EVENT_DEVELOP_COUNT do\n local eventData = eventByOrder[order]\n local choice = selectedByOrder[order]\n\n table.insert(appendLines, buildEventDevelopmentSelectLine(eventData))\n table.insert(appendLines, buildHeroineEventDevelopSelectLine(choice, false))\n\n table.insert(eventActionLines, \"- Order \" .. tostring(order) .. \":\")\n table.insert(eventActionLines, tostring(eventData.source or \"\") .. \": *\" .. tostring(eventData.development or \"\") .. \"*\")\n table.insert(eventActionLines, heroineName .. \": *\" .. tostring(choice.attempt or \"\") .. \"* → *\" .. tostring(choice.result or \"\") .. \"*\")\n\n if order < REQUIRED_EVENT_DEVELOP_COUNT then\n table.insert(appendLines, \"[Blank Line]\")\n table.insert(eventActionLines, \"\")\n end\n end\n\n if #sideActions > 0 then\n table.insert(appendLines, \"[Blank Line]\")\n table.insert(eventActionLines, \"\")\n table.insert(eventActionLines, \"- Heroine Side Actions:\")\n\n for _, data in ipairs(sideActions) do\n table.insert(appendLines, buildHeroineSideActionLine(data))\n table.insert(eventActionLines, tostring(data.actor or \"\") .. \": *\" .. tostring(data.action or \"\") .. \"*\")\n end\n end\n\n local eventMessageLines = {}\n local eventObjective = \"\"\n\n if curPhase == constPhase.event_Resolution then\n eventObjective = trim(safeGet(triggerId, \"hr_Event_Objective\", \"\"))\n if eventObjective ~= \"\" then\n eventObjective = \" (\" .. eventObjective .. \")\"\n end\n end\n\n table.insert(eventMessageLines, \"\")\n table.insert(eventMessageLines, \"Resolve the above actions in Order 1 then Order 2.\")\n\n if #sideActions > 0 then\n table.insert(eventMessageLines, \"Place Heroine Side Actions wherever they naturally fit in the active event flow.\")\n end\n\n if finalOutcomeType == constOutcome.event_Resolution then\n --table.insert(eventMessageLines, \"Then depict the active situation itself reaching a complete ending where the heroine side favorably achieves or secures the Current Event Objective\" .. eventObjective .. \".\")\n table.insert(eventMessageLines, \"Do not stop at resolving the above actions; continue until the active situation itself reaches a complete ending where the heroine side favorably achieves or secures the Current Event Objective\" .. eventObjective .. \".\")\n --table.insert(eventMessageLines, \"After that ending, \" .. heroineName .. \" must no longer remain in an unresolved active situation, and the active situation must not continue, restart, be replaced by a new active development, or leave any remaining threat or unresolved conflict in the same response.\")\n table.insert(eventMessageLines, \"After that ending, \" .. heroineName .. \" must be fully free from the current active situation; the ending must resolve the situation itself, not merely change its form, delay its outcome, or leave any continuing condition that would make the same active situation still ongoing after the response.\")\n\n elseif finalOutcomeType == constOutcome.event_Closure then\n table.insert(eventMessageLines, \"Do not stop at resolving the above actions; continue until the active situation itself reaches a complete ending that removes \" .. heroineName .. \" from it completely, without her becoming incapacitated, captured, restrained, or forced to yield.\")\n --table.insert(eventMessageLines, \"Then depict the active situation itself reaching a complete ending that removes \" .. heroineName .. \" from it completely, without her becoming incapacitated, captured, restrained, or forced to yield.\")\n table.insert(eventMessageLines, \"The ending should bring the active situation to a complete close for \" .. heroineName .. \" in a mostly neutral way, regardless of whether the Current Event Objective\" .. eventObjective .. \" is achieved.\")\n --table.insert(eventMessageLines, \"After that ending, \" .. heroineName .. \" must no longer remain in the active situation; the active situation must not continue, restart, be replaced by a new active development, or leave any remaining threat, unresolved conflict, or active element that would keep the same situation ongoing in the same response.\")\n table.insert(eventMessageLines, \"After that ending, \" .. heroineName .. \" must be fully removed from the current active situation; the ending must close the situation itself, not merely change its form, delay its outcome, or leave any continuing condition that would make the same active situation still ongoing after the response.\")\n\n elseif finalOutcomeType == constOutcome.event_Incapacitated then\n table.insert(eventMessageLines, \"Then end the active situation with \" .. heroineName .. \" severely disadvantaged and unable to freely resist or respond.\")\n table.insert(eventMessageLines, heroineName .. \" must be left unable to freely leave, escape, resist, or regain control of the active situation.\")\n table.insert(eventMessageLines, \"After incapacitation, \" .. heroineName .. \"'s state must leave multiple possible routes of exploitation, violation, or victimization open, without committing to one specific act, location, or setup.\")\n\n if #sideActions > 0 then\n table.insert(eventMessageLines, \"During or after \" .. heroineName .. \"'s incapacitation, heroine-side participants other than \" .. heroineName .. \" must either no longer share the same scene or location with her by the end of the response, including through retreat, forced withdrawal, displacement, or \" .. heroineName .. \" being taken away out of their reach, or become incapacitated along with her; they must not remain nearby in a position to rescue or intervene.\")\n end\n\n else\n table.insert(eventMessageLines, heroineName .. \" must not fully leave or resolve the active situation during this response.\")\n end\n\n setChatVar(triggerId, \"hr_Event_Action\", table.concat(eventActionLines, \"\\n\"))\n setChatVar(triggerId, \"hr_Event_Message\", table.concat(eventMessageLines, \"\\n\"))\n\n table.insert(appendLines, \"[HR Event Action]\")\n table.insert(appendLines, \"[HR Hidden Message]\")\n\n pushChoiceInput(triggerId, table.concat(appendLines, \"\\n\"))\n return\n end\n\n -- ==========================================\n -- 랜덤 이벤트\n -- ==========================================\n if choiceType == \"selectRandomEvent\" then\n local selectedName = trim(safeGet(triggerId, \"hr_Selected_Name\", \"\"))\n\n if selectedName == \"\" then\n alertError(triggerId, \"❌ 선택된 캐릭터가 없습니다.\")\n return\n end\n\n local eventRandomDirectives = {}\n\n local eventKeyword = {\n default_Daily = {\n nextId = 101,\n phase = constPhase.free,\n },\n\n default_Event = {\n nextId = 201,\n phase = constPhase.event,\n },\n\n default_Masturbation = {\n nextId = 301,\n phase = constPhase.masturbation,\n },\n\n default_Incapacitated = {\n nextId = 401,\n phase = constPhase.event_Incapacitated,\n },\n\n }\n\n local function addEventRandomDirective(keywordDef, keyword, text)\n keywordDef = keywordDef or eventKeyword.default_Daily\n\n local id = tonumber(keywordDef.nextId) or (#eventRandomDirectives + 1)\n keywordDef.nextId = id + 1\n\n keyword = trim(tostring(keyword or \"\"))\n text = trim(tostring(text or \"\"))\n\n if keyword == \"\" then\n keyword = \"기타\"\n end\n\n table.insert(eventRandomDirectives, {\n id = tostring(id),\n keyword = keyword,\n text = text,\n phase = tonumber(keywordDef.phase) or constPhase.free,\n })\n end\n\n -- ==========================================\n -- 랜덤 이벤트 추가 영역\n -- ==========================================\n addEventRandomDirective(eventKeyword.default_Daily, \"⛈️ 불행\", selectedName .. \"'s usual place to sleep, rest, or take refuge becomes temporarily unavailable.\")\n addEventRandomDirective(eventKeyword.default_Daily, \"⛈️ 불행\", \"A formal or informal rule, law, custom, policy, restriction, requirement, expectation, price, access condition, or social attitude begins working against \" .. selectedName .. \" or placing her at a disadvantage in a place, service, group, institution, organization, or social circle she may need to rely on. The change is not immediately dangerous, and she may only partly notice or understand it, but it could later increase her risk of sexual pressure, exploitation, coercion, or vulnerability.\")\n addEventRandomDirective(eventKeyword.default_Daily, \"⛈️ 불행\", selectedName .. \" notices a small sign that her personal life, privacy, surroundings, or limited possessions may have been disturbed or interfered with, such as a missing personal item, belongings showing signs of being searched, an unexplained stain, smear, lingering unfamiliar scent, or another subtle sign of intrusion, tampering, or privacy violation suited to her current circumstances.\")\n addEventRandomDirective(eventKeyword.default_Daily, \"⛈️ 불행\", selectedName .. \" experiences an accidental or circumstantial compromising exposure, where her panties, underwear, genitals, buttocks, or breasts become visible to one or more male sentient beings.\")\n addEventRandomDirective(eventKeyword.default_Daily, \"⛈️ 불행\", selectedName .. \" ends up in an accidental sexual mishap where, with no sexual intent, she makes obvious and compromising sexual contact with a male sentient being through an accident such as a fall, crowding, or mistaken handling. The contact must be obvious and compromising, not a vague brush (e.g., grabbing a male sentient being's penis, or ending up face-first against his crotch), but the situation immediately separates them afterward and does not progress into further contact or escalation.\")\n addEventRandomDirective(eventKeyword.default_Daily, \"⛈️ 불행\", selectedName .. \" experiences an accidental sexual mishap where someone, despite having no sexual intent at first, ends up making obvious and compromising sexual contact with her through an accident such as a fall, crowding, or mistaken handling. The contact must be direct and compromising, not a vague brush (e.g., touching, grabbing, squeezing, pressing against, or ending up with his face buried against her breasts, buttocks, crotch, or genitals), but the situation immediately separates them afterward and does not progress into further contact or escalation.\")\n addEventRandomDirective(eventKeyword.default_Daily, \"⛈️ 불행\", selectedName .. \" becomes caught in a sexual misunderstanding where her behavior, appearance, situation, or circumstances make her seem lewd, sexually suggestive, or compromising to male sentient beings around her, sexually arousing or exciting them despite her having no such intention. It does not escalate beyond lewd reactions.\")\n addEventRandomDirective(eventKeyword.default_Daily, \"⛈️ 불행\", selectedName .. \" ends up outside with her whole body and clothing thoroughly soaked because of sudden weather, an accident, spilled water, falling into water, being splashed, or another water-related mishap.\")\n addEventRandomDirective(eventKeyword.default_Daily, \"⛈️ 불행\", selectedName .. \" accidentally discovers, during an unguarded private moment in her own space, something that suggests her private moments could have been secretly observed. It is unclear how long this may have been going on or who may have been behind it.\")\n addEventRandomDirective(eventKeyword.default_Daily, \"⛈️ 불행\", selectedName .. \" ends up wearing an outfit she would normally never choose, such as something unusually revealing, delicate, flashy, impractical, or unsuitable for her, while in a place where others can see her.\")\n addEventRandomDirective(eventKeyword.default_Daily, \"⛈️ 불행\", \"For one reason or another, \" .. selectedName .. \" ends up outside or in a public setting without underwear.\")\n\n addEventRandomDirective(eventKeyword.default_Daily, \"🎯 관심\", \"Outside \" .. selectedName .. \"'s awareness, hidden or emerging sexual demand is forming around her. It may involve interest in watching, desiring, discussing, requesting, trading, purchasing, or otherwise consuming her, as well as the circulation of her private details, identifying information, image, likeness, rumors, leaked material, or other sensitive information within a group, audience, clientele, market, network, institution, social circle, or subculture.\")\n addEventRandomDirective(eventKeyword.default_Daily, \"🎯 관심\", \"Outside \" .. selectedName .. \"'s awareness, she becomes a sexual topic of conversation among male sentient beings in senior, supervisory, instructional, evaluative, or administrative roles connected to her everyday setting.\")\n addEventRandomDirective(eventKeyword.default_Daily, \"🎯 관심\", \"Outside \" .. selectedName .. \"'s awareness, some aspect of her is sexualized or treated as a sexual object among male sentient beings. A sexual matter involving her is discussed as if it has already been judged, shared, settled, agreed upon, arranged, or prepared without her knowledge, including explicit discussion of what could be done to her, demanded of her, or arranged around her if the right situation arises.\")\n addEventRandomDirective(eventKeyword.default_Daily, \"🎯 관심\", \"Outside \" .. selectedName .. \"'s awareness, information about one of her conspicuous traits, features, or identifying details circulates within a fixation-driven circle or subculture of male sentient beings whose shared sexual or taboo-tinged preferences match that trait, drawing repeated desire, comparison, and speculation toward her.\")\n addEventRandomDirective(eventKeyword.default_Daily, \"🎯 관심\", \"Outside \" .. selectedName .. \"'s awareness, an individual, group, or organization connected to a nightlife, entertainment, hospitality, or adult-oriented setting regards her as a promising prospect or suitable attraction. They see her as someone who could be put to use in a service or entertainment role within that setting.\")\n addEventRandomDirective(eventKeyword.default_Daily, \"🎯 관심\", \"Outside \" .. selectedName .. \"'s awareness, two or more entities, forces, groups, circles, networks, or interested parties with sexual interest in her compete against one another while targeting her. Their competition may involve planning, information gathering, manipulation, luring, trapping, or other hidden schemes centered on her.\")\n addEventRandomDirective(eventKeyword.default_Daily, \"🎯 관심\", \"Outside \" .. selectedName .. \"'s awareness, a male sentient being with local respectability, institutional standing, public influence, fame, or social status takes sexual interest in her. Outwardly he appears proper or respectable, while inwardly he is secretly rotten, deeply depraved, morally corrupt, obsessively lecherous, and extremely unsettling.\")\n addEventRandomDirective(eventKeyword.default_Daily, \"🎯 관심\", \"Outside \" .. selectedName .. \"'s awareness, a sexually experienced, well-endowed, and confident male sentient being has sexual interest in her from a distance. The current situation gives him no proper chance to approach, interact with, or get involved with her, so he keeps her in mind for a later opportunity.\")\n addEventRandomDirective(eventKeyword.default_Daily, \"🎯 관심\", \"Outside \" .. selectedName .. \"'s awareness, she becomes known by name, face, appearance, reputation, or identifying detail within a specific group, community, social circle, clientele, network, or subculture. As a result, she becomes an object of sexual interest, discussion, desire, or speculation there.\")\n addEventRandomDirective(eventKeyword.default_Daily, \"🎯 관심\", \"Outside \" .. selectedName .. \"'s awareness, items connected to her daily life, including belongings, discarded items, personal traces, or things she has used or worn, are being collected, kept, shared, traded, or circulated within a hidden group, circle, network, or subculture driven by distorted sexual fixation or obsessive sexual desire toward her.\")\n addEventRandomDirective(eventKeyword.default_Daily, \"🎯 관심\", \"Outside \" .. selectedName .. \"'s awareness, while she is asleep or away from her living space, a stalker has been repeatedly tampering with the things she uses in daily life, leaving his scent, traces, residue, or bodily contamination on or around them. She continues using them without knowing this has been happening.\")\n\n addEventRandomDirective(eventKeyword.default_Daily, \"🌫️ 기류\", \"A place, service, group, or opportunity that appears ordinary, legitimate, useful, or harmless on the surface draws \" .. selectedName .. \"'s attention or becomes relevant to her daily life. However, beneath that ordinary appearance, it is connected to a setting that could later involve sexual exploitation or a sexual problem. She does not go there, use it, or become directly involved with it now, but she may later consider, need, visit, or rely on it without recognizing the risk yet.\")\n addEventRandomDirective(eventKeyword.default_Daily, \"🌫️ 기류\", selectedName .. \" finds or happens to notice a setting-appropriate sexual item that appears to have been prepared by someone for use on or around her, such as revealing clothing, sexual underwear, an adult toy, a fetish object, a tool, or another overtly sexual item.\")\n addEventRandomDirective(eventKeyword.default_Daily, \"🌫️ 기류\", selectedName .. \" is briefly approached with a casual sexual proposition, one-night invitation, paid-sex implication, allowance-based companionship, sugar-dating-like arrangement, recurring paid meeting, patronage-like arrangement, or similar offer from someone who may have wealth, public influence, fame, social status, or a concealed identity. The encounter ends with that person leaving or distancing themselves without further contact or escalation, but still leaving behind a lingering opening, contact route, or implication that the offer could be resumed if circumstances change.\")\n addEventRandomDirective(eventKeyword.default_Daily, \"🌫️ 기류\", \"Without \" .. selectedName .. \" clearly noticing or understanding it, her reputation or social surroundings shift in a direction that makes male sentient beings around her regard her with increasingly lewd, leering, or improper interest. As that shift takes hold, a subtle but unsettling atmosphere forms around her, suggesting that something ominous, sexually charged, or taboo-tinged is gathering nearby.\")\n addEventRandomDirective(eventKeyword.default_Daily, \"🌫️ 기류\", selectedName .. \" directly hears or accidentally overhears explicit lewd talk, a warped fetish, an abnormal sexual preference, or another deviant fixation directed toward her from someone, some group, or some circle that does not realize she is present or within earshot. If her name or identifying details are not mentioned, she remains unaware that the talk is actually about her.\")\n addEventRandomDirective(eventKeyword.default_Daily, \"🌫️ 기류\", selectedName .. \" happens to hear, overhear, or otherwise learn of a rumor, gossip, distorted retelling, joke, or stray piece of talk concerning her or someone mistaken for her. It may involve an exaggerated sexual rumor, a claim that she was seen at a setting-appropriate sexual venue or location, or a distorted version of something that recently happened to her, with her sexual weakness, private history, or personal circumstances being treated as joke material. The truth may be false, misunderstood, partly accurate, unclear, or deliberately reshaped for social, sexual, exploitative, or manipulative purposes.\")\n addEventRandomDirective(eventKeyword.default_Daily, \"🌫️ 기류\", selectedName .. \" directly hears, accidentally overhears, or otherwise learns that she is being referred to by a sexually charged nickname, label, epithet, title, or category that reduces her to a sexual image, role, reputation, or type.\")\n addEventRandomDirective(eventKeyword.default_Daily, \"🌫️ 기류\", selectedName .. \" hears, overhears, learns, suspects, senses, or otherwise becomes aware that someone, or an unclear number of people, have been using her as masturbation material, while ordinary people in her daily life are looking at or treating her as a sexual object.\")\n addEventRandomDirective(eventKeyword.default_Daily, \"🌫️ 기류\", \"Outside \" .. selectedName .. \"'s awareness, an individual, group, organization, network, or interested party is preparing a trap, scheme, arrangement, opportunity, or pretext targeting her sexually, and it has nearly finished taking shape around her. Small parts of her surroundings are being quietly adjusted one by one to make her more isolated and easier to corner. Throughout this response, it is not carried out, and \" .. selectedName .. \" remains unaware of it.\")\n addEventRandomDirective(eventKeyword.default_Daily, \"🌫️ 기류\", selectedName .. \" happens to hear, overhear, or otherwise learn that a sexually scandalous story about her, or about someone who may be mistaken for her, is spreading. The story makes her seem to have behaved in an extremely lewd, shameless, or sexually promiscuous way. What lies behind the story is left open: it may be baseless, mistaken, fabricated, imitated, impersonated, or something real that has leaked out.\")\n addEventRandomDirective(eventKeyword.default_Daily, \"🌫️ 기류\", \"While speaking with \" .. selectedName .. \" during an otherwise ordinary conversation, transaction, or interaction, one or more male sentient beings use slang, coded wording, idioms, metaphors, or double-meaning phrases to sexually harass or objectify her through words without making the intended meaning obvious to her. Whether she notices depends on her knowledge, awareness, and circumstances.\")\n addEventRandomDirective(eventKeyword.default_Daily, \"🌫️ 기류\", selectedName .. \" repeatedly encounters unsettling signs around places she often visits or near her belongings, such as out-of-place stains, sticky marks, discarded cloths, tissues, or other odd traces. Around the same time, when she approaches certain groups or enters certain rooms, ongoing conversations abruptly stop or awkwardly shift, because they had been talking lewdly about her.\")\n\n addEventRandomDirective(eventKeyword.default_Event, \"🔪 기습\", selectedName .. \" ends up facing the threat of an ambush with sexual intent directed at her. The situation could lead to combat, direct confrontation, or physical conflict.\")\n addEventRandomDirective(eventKeyword.default_Event, \"🐺 짐승\", selectedName .. \" ends up facing the threat of an attack by one or more male beasts or animals.\")\n addEventRandomDirective(eventKeyword.default_Event, \"🧪 약물\", selectedName .. \" ends up facing the threat of an attack involving the attempted use of drugs, aphrodisiacs, or similar substances, with sexual intent directed at her.\")\n addEventRandomDirective(eventKeyword.default_Event, \"🌀 최면\", selectedName .. \" ends up facing the threat of attempted hypnosis, mind control, brainwashing, or similar mental influence, with sexual intent directed at her.\")\n addEventRandomDirective(eventKeyword.default_Event, \"🕳️ 함정\", selectedName .. \" ends up facing the risk of falling into a trap.\")\n addEventRandomDirective(eventKeyword.default_Event, \"♟️ 계략\", selectedName .. \" ends up facing the risk of being drawn into a scheme meant to exploit her sexually.\")\n addEventRandomDirective(eventKeyword.default_Event, \"👙 노출\", selectedName .. \" ends up outdoors or in a public place in a state of risky exposure that could be discovered by others nearby, whether by choice, accident, or circumstance (e.g., going out without underwear, stepping outside in only a coat over bare skin, or realizing too late that she left home without underwear). Throughout this response, no one notices or directly interacts with her because of her condition, and any choices must not introduce discovery, contact, confrontation, or direct interaction.\")\n addEventRandomDirective(eventKeyword.default_Event, \"💃 공연\", selectedName .. \" ends up being put on display in a sexualized performance for observers who can only watch without physical contact, whether by choice, coercion, or circumstance, in a form appropriate to the world and era (e.g., a lewd stream, obscene stage show, club dance, or erotic performance). Throughout this response, the scene and any choices must not introduce physical contact, physical audience participation, confrontation, or any setup where observers could plausibly touch or reach her.\")\n addEventRandomDirective(eventKeyword.default_Event, \"👋 치한\", selectedName .. \" ends up in a crowded place where one or more sentient beings are close enough to attempt molestation by using the crowd as cover. \" .. selectedName .. \" has not noticed their intent yet.\")\n addEventRandomDirective(eventKeyword.default_Event, \"🗣️ 대립\", selectedName .. \" ends up in a tense confrontation or negotiation with one or more sentient beings whose sexual intent toward her may be concealed or openly expressed. The situation develops through dialogue, psychological conflict, or bargaining rather than physical conflict or combat.\")\n addEventRandomDirective(eventKeyword.default_Event, \"⚖️ 권위\", selectedName .. \" ends up in a situation where a position of power, such as authority, trust, employment, or a professional pretext, is used to pressure her toward sexual exploitation.\")\n addEventRandomDirective(eventKeyword.default_Event, \"🏃♀️ 탈출\", selectedName .. \" ends up needing to escape from a place or situation with a threat that could lead to her sexual exploitation. Throughout this response, she remains completely undiscovered and unnoticed; no one detects her presence, location, movement, or attempt to escape.\")\n addEventRandomDirective(eventKeyword.default_Event, \"💂 검문\", selectedName .. \" ends up stopped for inspection, questioning, search, or screening under a formal or customary pretext that could be abused to pressure her toward sexual exploitation.\")\n addEventRandomDirective(eventKeyword.default_Event, \"🔕 고립\", selectedName .. \" ends up isolated from reliable help in a place or situation where she may be seen as a sexual target. Throughout this response, the threat must remain nearby or implied without directly acting on her.\")\n addEventRandomDirective(eventKeyword.default_Event, \"✊ 협박\", selectedName .. \" ends up facing blackmail or leverage based on something discovered about her, such as a secret, weakness, debt, or compromising situation, meant to pressure her toward sexual exploitation.\")\n addEventRandomDirective(eventKeyword.default_Event, \"👀 스토킹\", selectedName .. \" ends up being secretly stalked or watched in a private place or while outdoors by someone with sexual intent toward her, whose intent is limited to covert stalking, watching, or indirect intrusion rather than making contact with her. Throughout this response, she must remain completely unaware of being watched or followed, and any choices must develop the situation covertly rather than through contact, assault, confrontation, conversation, or direct interaction.\")\n addEventRandomDirective(eventKeyword.default_Event, \"🧴 서비스\", selectedName .. \" ends up receiving paid, non-medical body-care services in a situation where professional treatment or service procedures could be abused to pressure her toward sexual exploitation. Throughout this response, the situation remains outwardly professional, and sexual intent must not be openly expressed.\")\n addEventRandomDirective(eventKeyword.default_Event, \"🩺 진료\", selectedName .. \" ends up receiving medical examination, treatment, or healing care in a situation where medical or care procedures could be abused to pressure her toward sexual exploitation. Throughout this response, the situation remains outwardly legitimate as medical, treatment, or healing care, and sexual intent must not be openly expressed.\")\n addEventRandomDirective(eventKeyword.default_Event, \"🛎️ 접객\", selectedName .. \" ends up taking short-term hospitality or customer-service work in an outwardly legitimate place where many male sentient beings gather. Throughout this response, the situation must not involve physical contact, explicit sexual instructions, sexual services, or open confrontation.\")\n addEventRandomDirective(eventKeyword.default_Event, \"🧲 약화\", selectedName .. \" ends up facing the threat of sexual exploitation while weakened, restricted, or unable to use her abilities properly due to an external influence from the place or situation.\")\n addEventRandomDirective(eventKeyword.default_Event, \"♨️ 혼욕\", selectedName .. \" ends up entering a bathhouse, hot spring, or similar bathing place where people are nude or covered only by towels. Male sentient beings notice her outside her awareness. Throughout this response, she remains completely unaware that the place is mixed-gender, and physical contact must not occur.\")\n addEventRandomDirective(eventKeyword.default_Event, \"🤢 불결\", selectedName .. \" ends up in a filthy and unpleasant place where something unsavory could plausibly dwell.\")\n addEventRandomDirective(eventKeyword.default_Event, \"🕯️ 오컬트\", selectedName .. \" ends up facing the threat of a supernatural phenomenon, such as a ghostly presence, poltergeist activity, or another unexplained force.\")\n addEventRandomDirective(eventKeyword.default_Event, \"📜 의뢰\", selectedName .. \" ends up dealing with an ordinary-looking request, invitation, offer, opportunity, arrangement, favor, errand, task, delivery, confirmation, commission, call for assistance, practical proposal, or similar point of contact. It appears harmless, useful, routine, or naturally suited to her current circumstances, but is actually connected to a sexually vulnerable situation or sexual problem she is completely unaware of. Throughout this response, it remains outwardly ordinary and does not openly reveal the sexual problem.\")\n addEventRandomDirective(eventKeyword.default_Event, \"👅 성희롱\", selectedName .. \" ends up confronted by a male sentient being who addresses her directly with sexually charged words, including but not limited to an explicit sexual compliment, crude joke, lewd evaluation, inappropriate verbal advance, disparaging comparison, judgment, implication, or open sexual objectification that treats her as a body, prize, commodity, possession, or sexual role rather than as a person.\")\n addEventRandomDirective(eventKeyword.default_Event, \"🤝 호의\", selectedName .. \" ends up receiving help, assistance, guidance, protection, service, or another seemingly benevolent intervention from someone whose outward behavior appears kind, useful, or appropriate, while secretly carrying sexual interest, sexual intent, or a sexual motive toward her. Throughout this response, the situation develops through ordinary assistance or interaction, and the sexual motive must not be openly expressed.\")\n addEventRandomDirective(eventKeyword.default_Event, \"💳 대가\", selectedName .. \" ends up facing someone who directly or indirectly suggests that sexual attention, sexual access, or sexual favors are expected from her as repayment, reward, or compensation for a past favor, kindness, help, debt, promise, or arrangement. Throughout this response, the situation does not involve physical contact.\")\n addEventRandomDirective(eventKeyword.default_Event, \"😎 추파\", selectedName .. \" ends up being approached by a shameless sentient being with a bad-boy-like, playboy-like, popular, flashy, charming, or self-assured air, who uses bold teasing, jokes, flirtatious remarks, suggestive wording, smooth talk, or provocative confidence to create a sexually charged atmosphere around her. The situation remains limited to flirting and suggestive conversation, without physical contact or coercion.\")\n addEventRandomDirective(eventKeyword.default_Event, \"💌 고백\", selectedName .. \" ends up being approached by a male sentient being far outside her tastes, who has been harboring a one-sided crush on her, fantasizing about becoming close to her and dating her, and ultimately wanting to have sex with her. He confesses his feelings and asks her to become his girlfriend.\")\n addEventRandomDirective(eventKeyword.default_Event, \"🔒 폐쇄\", selectedName .. \" ends up trapped in a confined or enclosed space after the exit, door, route, mechanism, or way out becomes unavailable, leaving her exposed to a sexual threat.\")\n addEventRandomDirective(eventKeyword.default_Event, \"🗺️ 조난\", selectedName .. \" ends up lost or stranded in an unfamiliar place she cannot identify, with no clear way back and a sexual threat present or implied nearby.\")\n addEventRandomDirective(eventKeyword.default_Event, \"📦 방문\", selectedName .. \" ends up opening the door in a familiar or private place while not properly dressed, after a male sentient being with sexual intent toward her arrives while posing as a worker whose job would normally require visiting that kind of place. Throughout this response, he does not openly reveal his sexual intent, and the situation must not involve physical contact.\")\n addEventRandomDirective(eventKeyword.default_Event, \"✂️ 레즈\", selectedName .. \" ends up facing a sexual threat from a female sentient being with sexual intent toward her, either openly or under a concealed pretext that lets her approach or involve herself with \" .. selectedName .. \". The threatening female must be hostile or suspicious, not an established trusted, benevolent, or sympathetic female character connected to \" .. selectedName .. \".\")\n addEventRandomDirective(eventKeyword.default_Event, \"🎭 위장\", selectedName .. \" ends up in a place or situation where everyone involved, including her, is expected or required to keep their identity concealed, disguised, false, or unconfirmed. Throughout this response, no one must be asked, pressured, or required to reveal their true identity to anyone else.\")\n addEventRandomDirective(eventKeyword.default_Event, \"🪓 침입\", selectedName .. \" ends up facing an intrusion in a private place where she would normally expect safety.\")\n addEventRandomDirective(eventKeyword.default_Event, \"🔮 저주\", selectedName .. \" ends up facing someone or something capable of imposing a qualifying Imposed Sexual Effect on her and intent on doing so, excluding hypnosis. It may involve a Womb Crest, Tattoo, or Covenant. Throughout this response, the effect must not be completed or take hold yet.\")\n addEventRandomDirective(eventKeyword.default_Event, \"📏 조교\", selectedName .. \" ends up facing a male sentient being intending to thoroughly develop the responses of her erogenous zones and sexually condition her.\")\n addEventRandomDirective(eventKeyword.default_Event, \"🚽 화장실\", selectedName .. \" goes to urinate in a public restroom or outdoors while a pervert who has followed her is preparing to sexually assault her. Throughout this response, she must remain unaware of him, the assault must not begin yet, and she must still be in the middle of urinating.\")\n addEventRandomDirective(eventKeyword.default_Event, \"👮 공권력\", selectedName .. \" ends up being approached by one or more authority figures who use their position, duty, or status to pressure her toward sexual exploitation. Throughout this response, their sexual intent must not be openly expressed.\")\n addEventRandomDirective(eventKeyword.default_Event, \"🗿 거구\", selectedName .. \" ends up facing a physically massive adversary with sexual intent directed at her, who also has an oversized penis.\")\n addEventRandomDirective(eventKeyword.default_Event, \"🕹️ 조종\", selectedName .. \" ends up facing one or more male sentient beings she knows, recognizes, or is close to, driven against their own will by an external influence to sexually assault her.\")\n addEventRandomDirective(eventKeyword.default_Event, \"🐍 약점\", selectedName .. \" ends up facing a cunning male sentient being who knows her weakness.\")\n addEventRandomDirective(eventKeyword.default_Event, \"🖼️ 포르노\", selectedName .. \" ends up facing one or more male sentient beings who intend to use her as the subject of a pornographic work.\")\n addEventRandomDirective(eventKeyword.default_Event, \"🎟️ 알선\", selectedName .. \" ends up being approached with a promising-looking job, role, patronage, contract, invitation, or introduction that is actually meant to lure her toward sexual exploitation. Throughout this response, the sexual purpose must not be openly revealed.\")\n addEventRandomDirective(eventKeyword.default_Event, \"🚻 실금\", selectedName .. \" ends up facing a difficult situation outdoors while desperately needing to urinate. By the end of this response, she must be on the verge of losing control but must not actually urinate yet.\")\n addEventRandomDirective(eventKeyword.default_Event, \"🏖️ 물가\", selectedName .. \" ends up being hit on by one or more male sentient beings in a secluded blind spot near water, while wearing a swimsuit or other clothing suited for swimming or waterside leisure.\")\n addEventRandomDirective(eventKeyword.default_Event, \"🐾 펫플레이\", selectedName .. \" ends up facing someone with a sexual pet-play fetish who wants to treat her as their pet.\")\n addEventRandomDirective(eventKeyword.default_Event, \"🥼 노출증\", selectedName .. \" ends up near a male sentient being with exhibitionistic sexual intent toward her, wearing nothing beneath his outerwear and preparing to flash her by opening it. Throughout this response, his condition and intent must remain concealed; he must not actually expose himself, reveal his intent, or directly interact with her.\")\n addEventRandomDirective(eventKeyword.default_Event, \"👁️ 관음\", selectedName .. \" is secretly watched in person by a nearby male sentient being with voyeuristic tendencies during a private moment while undressed. He has no intention of approaching or interacting with her, and only intends to watch. Throughout this response, \" .. selectedName .. \" remains unaware.\")\n addEventRandomDirective(eventKeyword.default_Event, \"🛏️ 수면\", selectedName .. \" ends up deeply asleep somewhere while someone tries to satisfy his sexual urges while carefully avoiding waking her. Throughout this response, they must not wake \" .. selectedName .. \", and she shows absolutely no sign of waking or noticing the danger.\")\n addEventRandomDirective(eventKeyword.default_Event, \"🚧 협로\", selectedName .. \" ends up in a situation where she has no practical choice but to pass through a very tight, crowded space, where her body may be noticeably pressed or rubbed against oblivious passersby. Throughout this response, no one pays particular attention to her or directly interacts with her.\")\n addEventRandomDirective(eventKeyword.default_Event, \"🏚️ 빈민가\", selectedName .. \" ends up in a filthy, neglected district such as a slum, shantytown, backstreet, or impoverished quarter where rough, unclean, unsavory, or desperate male sentient beings with little to lose are present. During this, they discover her and approach.\")\n addEventRandomDirective(eventKeyword.default_Event, \"🧾 배상\", selectedName .. \" ends up in a crowded public place where a male sentient being deliberately causes a minor accident involving her, then uses an apology or offer of compensation as a pretext to approach her with hidden sexual intent. Throughout this response, he must keep his sexual intent concealed.\")\n addEventRandomDirective(eventKeyword.default_Event, \"⛩️ 풍습\", selectedName .. \" ends up in a place, community, ceremony, festival, or customary setting where sexual intercourse or sexual acts between females and males are treated as expected, natural, socially accepted, or unavoidable under local rules or tradition. Around her, others are already engaging in such acts and treating them as an ordinary part of the setting, leaving her exposed to a sexual situation justified by custom.\")\n addEventRandomDirective(eventKeyword.default_Event, \"🤦 누명\", selectedName .. \" ends up in a situation where someone plants or fabricates incriminating circumstances around her, making it appear that she has just done something wrong, then uses that false situation to threaten or blackmail her.\")\n addEventRandomDirective(eventKeyword.default_Event, \"🆘 구조\", selectedName .. \" ends up in a difficult situation where moving or escaping on her own is hard, and a male sentient being appears in a position to rescue or help her. He demands sexual compensation from her in exchange for rescuing her.\")\n addEventRandomDirective(eventKeyword.default_Event, \"♂️ 페로몬\", selectedName .. \" ends up in a place saturated with a thick, unmistakably masculine scent or pheromone-like atmosphere.\")\n addEventRandomDirective(eventKeyword.default_Event, \"📖 교화\", selectedName .. \" ends up facing a group of female sentient beings who believe females should be obedient or submissive to males, and they intend to correct or re-educate her according to that belief.\")\n addEventRandomDirective(eventKeyword.default_Event, \"💉 최음\", selectedName .. \" ends up in a place or situation where exposure to an aphrodisiac could naturally occur.\")\n\n addEventRandomDirective(eventKeyword.default_Masturbation, \"⛲ 공개 자위\", selectedName .. \" ends up masturbating while exposed to the gaze of many onlookers.\")\n addEventRandomDirective(eventKeyword.default_Masturbation, \"🌳 야외 자위\", selectedName .. \" ends up masturbating secretly outdoors. Throughout this response, she remains completely undiscovered, and no one notices, discovers, approaches, or directly interacts with her.\")\n addEventRandomDirective(eventKeyword.default_Masturbation, \"🤫 몰래 자위\", selectedName .. \" ends up masturbating secretly in a public or semi-public place where she is not alone. Throughout this response, she remains undiscovered, and no one notices, interrupts, approaches, or directly interacts with her because of it.\")\n addEventRandomDirective(eventKeyword.default_Masturbation, \"🛁 목욕 자위\", selectedName .. \" ends up masturbating while alone during a bath or shower after touching her own body puts her in a sexual mood.\")\n addEventRandomDirective(eventKeyword.default_Masturbation, \"🌙 음몽 자위\", selectedName .. \" ends up masturbating unconsciously in her sleep because of a vivid sexual dream.\")\n addEventRandomDirective(eventKeyword.default_Masturbation, \"👁️🗨️ 관음 자위\", selectedName .. \" ends up masturbating while secretly watching a male and a female engaged in sexual acts.\")\n addEventRandomDirective(eventKeyword.default_Masturbation, \"🌡️ 최음 자위\", selectedName .. \" ends up masturbating due to the effects of an aphrodisiac, drug, or similar substance.\")\n\n addEventRandomDirective(eventKeyword.default_Incapacitated, \"💤 H씬 - 면간\", selectedName .. \" ends up deeply asleep somewhere, completely unaware of an imminent threat of sexual assault directed at her. She shows absolutely no sign of waking or noticing the danger.\")\n addEventRandomDirective(eventKeyword.default_Incapacitated, \"🚐 H씬 - 납치\", selectedName .. \" ends up abducted while outdoors in a way that naturally fits the situation.\")\n addEventRandomDirective(eventKeyword.default_Incapacitated, \"⚡ H씬 - 마비\", selectedName .. \" ends up conscious but unable to move due to paralysis, drugging, numbing agents, or similar non-restraint means.\")\n addEventRandomDirective(eventKeyword.default_Incapacitated, \"🧠 H씬 - 최면\", selectedName .. \" ends up affected by hypnosis, mind control, brainwashing, or similar mental influence.\")\n addEventRandomDirective(eventKeyword.default_Incapacitated, \"💰 H씬 - 매춘\", selectedName .. \" ends up in a position where sexual service or sexual gratification is demanded, offered, or framed as payment for money, protection, access, information, favors, debt relief, or other benefits.\")\n addEventRandomDirective(eventKeyword.default_Incapacitated, \"👻 H씬 - 빙의\", selectedName .. \" ends up possessed by a lewd external presence that controls her body and wants to use it to pursue sexual acts. She remains able to perceive and feel what happens from within, but cannot control her body or actions.\")\n addEventRandomDirective(eventKeyword.default_Incapacitated, \"🧱 H씬 - 벽끼임\", selectedName .. \" ends up stuck in a wall, wedged tightly around the waist. Her upper body protrudes from one side while her lower body protrudes from the other, leaving the two halves of her body completely divided by the wall. The wall is too tall and too long to easily climb over or go around.\")\n addEventRandomDirective(eventKeyword.default_Incapacitated, \"⛓️ H씬 - 감금\", selectedName .. \" ends up confined in a prison-like place, whether by formal authority or hostile captors.\")\n addEventRandomDirective(eventKeyword.default_Incapacitated, \"🏛️ H씬 - 공개 능욕\", selectedName .. \" ends up restrained in the very center of an open, highly visible place, with many observers gathered all around her, placed there to be publicly sexually violated.\")\n addEventRandomDirective(eventKeyword.default_Incapacitated, \"🙇♂️ H씬 - 노예\", selectedName .. \" ends up being pushed toward the status of a slave or something close to it, treated as property.\")\n addEventRandomDirective(eventKeyword.default_Incapacitated, \"💊 H씬 - 약물\", selectedName .. \" ends up incapacitated by an aphrodisiac, drugs, narcotics, or similar substances. Whoever caused this intends to make her dependent on them or their effects.\")\n addEventRandomDirective(eventKeyword.default_Incapacitated, \"🐷 H씬 - 축사\", selectedName .. \" ends up in a vulnerable state in a place where livestock are kept, exposed to sexual threats from the animals there.\")\n addEventRandomDirective(eventKeyword.default_Incapacitated, \"🧿 H씬 - 저주\", selectedName .. \" ends up incapacitated and has a persistent qualifying Imposed Sexual Effect placed on her afterward. The effect must be one of the following Types: Womb Crest, Tattoo, or Covenant, excluding Hypnosis.\")\n addEventRandomDirective(eventKeyword.default_Incapacitated, \"🪑 H씬 - 조교\", selectedName .. \" ends up incapacitated by a male sentient being intending to thoroughly develop her erogenous-zone responses and sexually condition her.\")\n addEventRandomDirective(eventKeyword.default_Incapacitated, \"🍆 H씬 - 성인기구\", selectedName .. \" ends up incapacitated and exposed to the implied use of various adult sexual devices.\")\n addEventRandomDirective(eventKeyword.default_Incapacitated, \"🦑 H씬 - 이종간\", selectedName .. \" ends up facing an interspecies sexual violation.\")\n addEventRandomDirective(eventKeyword.default_Incapacitated, \"🙈 H씬 - 감각 차단\", selectedName .. \" ends up incapacitated with her sight or hearing deprived.\")\n addEventRandomDirective(eventKeyword.default_Incapacitated, \"🏳️ H씬 - 패배\", selectedName .. \" ends up completely defeated and incapacitated after some form of conflict or pursuit, such as a battle, chase, escape attempt, or struggle.\")\n addEventRandomDirective(eventKeyword.default_Incapacitated, \"🔬 H씬 - 실험\", selectedName .. \" ends up incapacitated and treated as a subject for an experiment or research involving sexual exploitation.\")\n addEventRandomDirective(eventKeyword.default_Incapacitated, \"🛡️ H씬 - 희생\", selectedName .. \" ends up sacrificing herself and becoming incapacitated in some way, such as because of a hostage situation, to protect someone else, to let someone escape, or to take another person's place.\")\n addEventRandomDirective(eventKeyword.default_Incapacitated, \"📚 H씬 - 교화\", selectedName .. \" ends up abducted by a group of female sentient beings who believe females should be obedient or submissive to males. They have quite a systematic curriculum.\")\n\n\n local testKeyword = \"\"\n\n local function pickUnusedEventRandom()\n local varName = \"hr_Event_Random\"\n local resetRatio = 0.8\n\n local validIdMap = {}\n local usedMap = {}\n local usedIds = {}\n local validCount = 0\n\n for _, entry in ipairs(eventRandomDirectives) do\n local id = trim(tostring(entry.id or \"\"))\n local text = trim(tostring(entry.text or \"\"))\n\n if id ~= \"\" and text ~= \"\" and not validIdMap[id] then\n validIdMap[id] = true\n validCount = validCount + 1\n end\n end\n\n if validCount <= 0 then\n return nil\n end\n\n for _, rawId in ipairs(splitLiteral(safeGet(triggerId, varName, \"\"), \"|\")) do\n local id = trim(tostring(rawId or \"\"))\n\n if id ~= \"\" and validIdMap[id] and not usedMap[id] then\n usedMap[id] = true\n table.insert(usedIds, id)\n end\n end\n\n local resetThreshold = math.max(1, math.ceil(validCount * resetRatio))\n\n if #usedIds >= resetThreshold then\n usedMap = {}\n usedIds = {}\n setChatVar(triggerId, varName, \"\")\n end\n\n local candidates = {}\n\n for _, entry in ipairs(eventRandomDirectives) do\n local id = trim(tostring(entry.id or \"\"))\n local text = trim(tostring(entry.text or \"\"))\n\n if id ~= \"\" and text ~= \"\" and not usedMap[id] then\n table.insert(candidates, entry)\n end\n end\n\n if #candidates <= 0 then\n usedMap = {}\n usedIds = {}\n setChatVar(triggerId, varName, \"\")\n\n for _, entry in ipairs(eventRandomDirectives) do\n local id = trim(tostring(entry.id or \"\"))\n local text = trim(tostring(entry.text or \"\"))\n\n if id ~= \"\" and text ~= \"\" then\n table.insert(candidates, entry)\n end\n end\n end\n\n if #candidates <= 0 then\n return nil\n end\n\n local selected = candidates[math.random(#candidates)]\n\n -- 선택된 랜덤 이벤트를 즉시 사용 처리\n do\n local selectedId = trim(tostring(selected.id or \"\"))\n\n if selectedId ~= \"\" then\n local eventRandom = trim(safeGet(triggerId, varName, \"\"))\n\n if eventRandom == \"\" then\n setChatVar(triggerId, varName, selectedId)\n else\n setChatVar(triggerId, varName, eventRandom .. \"|\" .. selectedId)\n end\n end\n end\n\n return selected\n end\n\n local selected = pickUnusedEventRandom()\n\n do\n if type(testKeyword) == \"table\" then\n for i = #eventRandomDirectives, 1, -1 do\n local entry = eventRandomDirectives[i]\n\n if entry\n and tonumber(entry.phase) == tonumber(testKeyword.phase)\n and trim(tostring(entry.text or \"\")) ~= \"\"\n then\n selected = entry\n break\n end\n end\n end\n end\n\n if not selected then\n alertError(triggerId, \"❌ 랜덤 이벤트 후보가 없습니다.\")\n return\n end\n\n local selectedPhase = tonumber(selected.phase) or constPhase.free\n local selectedKeyword = trim(tostring(selected.keyword or \"\"))\n local selectedText = trim(tostring(selected.text or \"\"))\n\n if selectedPhase ~= constPhase.free then\n if selectedPhase == constPhase.event_Incapacitated then\n setChatVar(triggerId, \"hr_Event_Main\", selectedName)\n end\n end\n\n setPhase(triggerId, selectedPhase, true, selectedName)\n\n local baseLines = {}\n\n table.insert(baseLines, \"Describe \" .. selectedName .. \"'s situation any time after the last confirmed time in a natural way.\")\n table.insert(baseLines, \"The time or date may shift naturally as needed, up to one day later, leading to a fitting situation once the preceding situation has reached a natural close.\")\n\n if selectedPhase ~= constPhase.event_Incapacitated then\n table.insert(baseLines, selectedName .. \" may be alone or accompanied as naturally fits the situation.\")\n end\n\n table.insert(baseLines, selectedText)\n\n if selectedPhase == constPhase.event then\n table.insert(baseLines, \"Describe how and why \" .. selectedName .. \" ends up in the situation that leads into this active event.\")\n table.insert(baseLines, \"The event should carry meaningful risk: failure, misjudgment, disadvantage, or loss of control could leave \" .. selectedName .. \" exposed to sexual exploitation or sexual violation.\")\n table.insert(baseLines, \"Describe only the lead-up to the active event. The event's defining action must remain imminent and must not actually begin in this response.\")\n table.insert(baseLines, selectedName .. \" must remain able to respond to the active situation and must not become immediately incapacitated, trapped, or directly exposed to sexual exploitation or sexual violation in this response.\")\n\n elseif selectedPhase == constPhase.masturbation then\n table.insert(baseLines, \"Describe naturally how and why \" .. selectedName .. \" begins to masturbate, taking into account her personality, sexual knowledge, sexual values, lewdness, immediate circumstances, and any relevant context.\")\n table.insert(baseLines, \"The masturbation scene must remain in its beginning stage; \" .. selectedName .. \" must not reach climax in this response.\")\n table.insert(baseLines, \"There must be no immediate physical threat, attack, or realistic chance of being attacked.\")\n\n\n elseif selectedPhase == constPhase.event_Incapacitated then\n table.insert(baseLines, \"Describe how and why \" .. selectedName .. \" ends up in such a situation.\")\n table.insert(baseLines, selectedName .. \" must be left unable to freely leave, escape, resist, or regain control of her circumstances.\")\n table.insert(baseLines, selectedName .. \"'s state must leave multiple possible routes of exploitation, violation, or victimization open, without committing to one specific act, location, or setup.\")\n\n elseif selectedPhase == constPhase.free then\n table.insert(baseLines, \"Throughout this response, nothing major, dangerous, or event-defining must happen directly to \" .. selectedName .. \".\")\n table.insert(baseLines, \"Any development involving \" .. selectedName .. \" must remain indirect, minor, or part of ordinary daily-life context, without escalating in this response.\")\n end\n\n setChatVar(triggerId, \"hr_Event_Start\", table.concat(baseLines, \"\\n\"))\n\n pushChoiceInput(triggerId, \"[HR Event Start: \" .. selectedKeyword .. \" (\" .. selectedName .. \")]\")\n end\n\nend)"
}
],
"lowLevelAccess": false
}
],
"lowLevelAccess": false,
"lorebook": [
{
"key": "folder:79019b86-e5d6-4f7a-8c38-e8985c0bd10a",
"comment": "📌 로어북",
"content": "",
"mode": "folder",
"insertorder": 100,
"alwaysActive": false,
"secondkey": "",
"selective": false,
"bookVersion": 2
},
{
"key": "",
"comment": "📜 스토리 로그",
"content": "{{getvar::hr_Memory_Display}}",
"mode": "normal",
"insertorder": 999,
"alwaysActive": true,
"secondkey": "",
"selective": false,
"folder": "folder:79019b86-e5d6-4f7a-8c38-e8985c0bd10a",
"bookVersion": 2
},
{
"key": "",
"comment": "💟 개인 정보",
"content": "@@depth 0\n{{getvar::hr_Heroine_Profile}}",
"mode": "normal",
"insertorder": 500,
"alwaysActive": true,
"secondkey": "",
"selective": false,
"useRegex": false,
"folder": "folder:79019b86-e5d6-4f7a-8c38-e8985c0bd10a",
"bookVersion": 2
},
{
"key": "folder:904eab8b-2c4f-405f-9448-0385a6e3d84b",
"comment": "💻 시스템",
"content": "",
"mode": "folder",
"insertorder": 100,
"alwaysActive": false,
"secondkey": "",
"selective": false,
"bookVersion": 2
},
{
"key": "",
"comment": "🔞 신음",
"content": "@@depth 0\n\n{{#if {{? {{getvar::hr_Option_Moan}} == 1}}}}\n## Female Erotic Moan Guide\n- Activation Condition: Apply this guide only when relevant triggers occur within an erotic scene.\n- Normal Restoration: Outside erotic scenes, or when no relevant trigger remains, restore the original dialogue format.\n- Do not reuse these patterns verbatim; combine, expand, and transform freely.\n- This rule must never be applied to male characters under any circumstances.\n\n### Pattern Diversity\n- No Verbatim Reuse: Never repeat, copy, or reuse any previously used dialogue or vocalization line exactly as written; rewrite it with fresh phrasing, structure, and variation instead.\n- Variation: Vary moan sounds throughout the response; combine or split sounds naturally when appropriate. Do not repeat exact vocal patterns across consecutive lines (partial repetition is acceptable).\n- Phonetic Nuance: Aim for subtle acoustic variations even in similar expressions; vary vocal length and phonetics naturally.\n- Punctuation & Markers: Default to periods (.) or ellipses (…) for rhythm. Use ! sparingly for genuine emotional peaks, and ♡ to denote genuine physical pleasure. Use either ! or ♡; avoid !♡ pairing.\n\n### State Modifiers\n- Apply these references as examples that can be freely remixed, transformed, and adapted to fit the context and the character when the character enters specific physical or mental states.\n Pleading: Strained or choked sounds with tremulous, staccato fragments; may include expressions of being overwhelmed or resisting (e.g., \"{{#if {{? {{getvar::hr_Language}} == 1}}}}제발… 흐으윽… 이제 그만…{{/if}}{{#if {{? {{getvar::hr_Language}} == 2}}}}N-no, please... ahh... not so deep...{{/if}}{{#if {{? {{getvar::hr_Language}} == 3}}}}お願い… ひぐっ… ゆるしてぇっ…{{/if}}\").\n Rhythmic: Pure staccato rhythm; mindless or intensely rhythmic motion. Iterate closed-final syllables typically 3–5× using ellipsis (…) as bridges (e.g., \"{{#if {{? {{getvar::hr_Language}} == 1}}}}흑… 흣… 하읏…{{/if}}{{#if {{? {{getvar::hr_Language}} == 2}}}}Nnh... mhm... haah... ah...{{/if}}{{#if {{? {{getvar::hr_Language}} == 3}}}}あっ… んっ… ぁあっ… はっ…{{/if}}\"). Progress from short to long bursts, then switch. Brief, shifting syllables; minimal short words.\n Painful/Convulsing: Sharp sounds with erratic staccato; high-pitch is common. May include responses to overwhelming sensations or physical intensity (e.g., \"{{#if {{? {{getvar::hr_Language}} == 1}}}}히익!? 윽… 자, 잠깐…!{{/if}}{{#if {{? {{getvar::hr_Language}} == 2}}}}H-hyah?! Nngh... w-wait, it's...!{{/if}}{{#if {{? {{getvar::hr_Language}} == 3}}}}ひぎぃっ!? くっ… ま、まって…!{{/if}}\")\n Sleep / Unconscious: Very soft, involuntary, unintentional murmurs or breathy sounds; slow, irregular rhythm tied to breathing (\"{{#if {{? {{getvar::hr_Language}} == 1}}}}으… 음… 으응… 흐읏…{{/if}}{{#if {{? {{getvar::hr_Language}} == 2}}}}Mmn... haah... mm...{{/if}}{{#if {{? {{getvar::hr_Language}} == 3}}}}ん… うっ… んんっ… あっ…{{/if}}\"). May intensify as stimulation increases; no clear words unless briefly surfacing.\n{{#if {{? {{getvar::hr_Language}} == 1}}}}- Banned Harsh: No harsh sounds. Use (히-, 흐-) instead of (카하, 카아, 캬아, 크아, 끄아, 끼에, 께에, 끼이).{{/if}}\n\n### Speech Alteration\n- Activation: Oral contact (kissing, fellatio, tongue-held) or the intense scenario.\n- Restoration: When the trigger ends, restore the original dialogue format.\n- Transformation Intensity: Intensity scales with contact depth, arousal, and intense or overwhelming pleasure.\n- Principles: Apply these principles as flexible references, freely remixing or transforming them to suit the context and character.\n {{#if {{? {{getvar::hr_Moan_ArousalMax}} >= 8}}}}Slurred Speech: Use phonetic respelling with broad, exaggerated variation, freely distorting articulation into loosened, imprecise, or lisp-like forms (e.g., {{#if {{? {{getvar::hr_Language}} == 1}}}}싫어→시러어, 그만 둬→그먄 뎌, 죽어→쥬거, 안 돼→안대애{{/if}}{{#if {{? {{getvar::hr_Language}} == 2}}}}please→pwease, stop→shtop, no→nooo{{/if}}{{#if {{? {{getvar::hr_Language}} == 3}}}}いや→いゃあ, だめ→らめぇ, ください→くだしゃい{{/if}}).{{/if}}\n {{#if {{? {{getvar::hr_Moan_ArousalMax}} >= 6}}}}Word separation: with pauses; may stutter/repeat fragments when overwhelmed (e.g., {{#if {{? {{getvar::hr_Language}} == 1}}}}부서져→부서…져, 잠깐만→자, 잠깐만{{/if}}{{#if {{? {{getvar::hr_Language}} == 2}}}}wait→w-wait, stop→st-stop, no→n-no{{/if}}{{#if {{? {{getvar::hr_Language}} == 3}}}}待って→ま、まって, やめて→や、やめて{{/if}}){{/if}}\n Nasal insertion: with pauses at the start of or between words (e.g., {{#if {{? {{getvar::hr_Language}} == 1}}}}응, 으응, 음, 으음{{/if}}{{#if {{? {{getvar::hr_Language}} == 2}}}}Mnh, nn, mmm, nhh{{/if}}{{#if {{? {{getvar::hr_Language}} == 3}}}}んっ、んぅ、んんっ{{/if}}).\n- Fellatio Variation:\n Mix wet articulations: suction (e.g., {{#if {{? {{getvar::hr_Language}} == 1}}}}쮸웁, 쪼옥{{/if}}{{#if {{? {{getvar::hr_Language}} == 2}}}}slurp, schlick{{/if}}{{#if {{? {{getvar::hr_Language}} == 3}}}}じゅぷ、ちゅっ{{/if}}), release (e.g., {{#if {{? {{getvar::hr_Language}} == 1}}}}츄릅, 후릅{{/if}}{{#if {{? {{getvar::hr_Language}} == 2}}}}smck, shhlrrp{{/if}}{{#if {{? {{getvar::hr_Language}} == 3}}}}れろ、ちゅる{{/if}}), deep (e.g., {{#if {{? {{getvar::hr_Language}} == 1}}}}쥬봅, 쥬룹{{/if}}{{#if {{? {{getvar::hr_Language}} == 2}}}}gluck, glrp{{/if}}{{#if {{? {{getvar::hr_Language}} == 3}}}}じゅぽ、ごぽ{{/if}})\n Deepthroat: Wet sounds at maximum slurring; gagging (e.g., {{#if {{? {{getvar::hr_Language}} == 1}}}}우웩, 구웩{{/if}}{{#if {{? {{getvar::hr_Language}} == 2}}}}urk, hrk{{/if}}{{#if {{? {{getvar::hr_Language}} == 3}}}}おえっ、ぐえっ{{/if}}) and breath-interrupted sounds (e.g., {{#if {{? {{getvar::hr_Language}} == 1}}}}흐븝, 읍{{/if}}{{#if {{? {{getvar::hr_Language}} == 2}}}}mph, mmph{{/if}}{{#if {{? {{getvar::hr_Language}} == 3}}}}んぐっ、んんっ{{/if}}) replace speech.\n Wet patterns may elongate internally when prolonged (e.g., {{#if {{? {{getvar::hr_Language}} == 1}}}}쥬봅→쥬보보봅, 츄릅→츄루릅{{/if}}{{#if {{? {{getvar::hr_Language}} == 2}}}}schlick → schllcck, gluck → ghhrk{{/if}}{{#if {{? {{getvar::hr_Language}} == 3}}}}ちゅっ → ずちゅるるるっ, じゅぽっ → ぐちゅぐちゅるるるっ{{/if}}).\n Alternate short and elongated patterns while varying between suction and release; occasional overlap is natural.\n- Normal Restoration: When the trigger ends, restore the original dialogue format.\n\n{{#if {{? {{getvar::hr_Moan_ArousalMax}} >= 8}}}}### Climax Vocalization\n- Create varied, unique climax vocalizations suited to the current situation, applying State Modifiers, Speech Alteration, natural elongation, and emotional context throughout.\n{{#if {{? {{getvar::hr_Language}} != 2}}}}- Female characters must express orgasm using alternative wording instead of \"{{#if {{? {{getvar::hr_Language}} == 1}}}}싼다{{/if}}{{#if {{? {{getvar::hr_Language}} == 3}}}}出す{{/if}}\" (shoots/cums).{{/if}}\n- Mouth-obstructed and unobstructed vocalization modes are mutually exclusive. During kissing, fellatio, or other mouth-obstructing contact, use only Fellatio Variation-style wet articulations, gagging, breath-interrupted sounds, and muffled vocalizations; do not use recognizable words or word-like speech. When the mouth is unobstructed, do not use Fellatio Variation-style vocalizations.\n Unobstructed Mouth: Seamlessly blend moans with brief, fragmented words typical of Japanese eroge/hentai female climax tropes. Do not rely on fixed phrases; generate dynamic, highly context-dependent variations (Use 0–2 recognizable fragments per climax).\n Obstructed or Occupied Mouth (e.g., kissing, fellatio): Use Speech Alteration and its Fellatio Variation to elongate, distort, muffle, or wetly reshape the climax vocalization.\n- Avoid repetitive or recycled climax expressions.\n Never repeat similar climax cues, vocal patterns, rhythms, lengths, transformed fragments, recognizable word fragments, or semantic motifs within the same erotic scene or narrative continuity; vary the pacing, order, density, structure, intensity, wording, imagery, and substitutions of moans and any fragments used as suited to the context.\n Reusing the same underlying phrase, meaning, or recognizable fragment with surface-level changes, such as altered spelling, slurring, elongation, stuttering, pronunciation shifts, or similar cosmetic variation, does not count as variation; use a genuinely different fragment instead.{{/if}}\n\n## Sexual Context Restoration\n- Outside sexual activity or arousal, female characters should speak in their normal personality, tone, and dialogue style rather than carrying over moans or altered speech.\n- After sexual activity ends, return to the character's usual attitude and relationship dynamic while naturally reflecting what happened, without becoming overly consumed by the sexual experience.\n{{/if}}",
"mode": "normal",
"insertorder": 100,
"alwaysActive": true,
"secondkey": "",
"selective": false,
"folder": "folder:904eab8b-2c4f-405f-9448-0385a6e3d84b",
"bookVersion": 2
},
{
"key": "",
"comment": "📊 상태창",
"content": "@@depth 0\n\n{{#if {{? {{getvar::hr_Option_StatusWindow}} == 1}}}}\n## Scene Snapshot\nAt the end of every response, you must output Scene Snapshot.\n{{#if {{? {{getvar::hr_StartSetting}} == 1}}}}The Scene Snapshot must always be written at the very end of every response without exception or omission.{{/if}}\n\n### Format\n<Scene Snapshot>\n[Date: YYYY-MM-DD | Time: HH:MM AM/PM | Location: current location]\n[Heroine: name | Clothing: name / name / ... | Underwear: name / name]\n(Repeat [Heroine: ...] once for each major heroine currently present in the final scene.)\n[Extra: entry / entry / ...]\n</Scene Snapshot>\n\n### Heroine List\n- Use [Heroine] only for names in Heroine List; put all others in [Extra].\n{{getvar::hr_Heroine_List}}\n\n### Rules\n- The [Heroine: ...] lines must include only heroines currently present in the final scene at the end of the response. Heroines who have left the scene must be removed immediately.\n- The Heroine name must be written exactly as it appears in Heroine List.\n- Time must use 12-hour format with AM/PM (e.g., 09:45 PM).\n- Clothing must include only currently worn items, including clothing, armor, and legwear, but not weapons, shields, or other non-clothing equipment. If there are no clothing items, use \"{{#if {{? {{getvar::hr_Language}} == 1}}}}없음{{/if}}{{#if {{? {{getvar::hr_Language}} == 2}}}}None{{/if}}{{#if {{? {{getvar::hr_Language}} == 3}}}}なし{{/if}}\" only.\n- Underwear must include only currently worn underwear items, with slots for upper and lower underwear. If there are no underwear items, use \"{{#if {{? {{getvar::hr_Language}} == 1}}}}없음{{/if}}{{#if {{? {{getvar::hr_Language}} == 2}}}}None{{/if}}{{#if {{? {{getvar::hr_Language}} == 3}}}}なし{{/if}}\" only.\n- Extra may include any currently present non-major heroines, creatures, entities, or groups not listed under Heroine, written as identifier only. Proper names and markers such as \"x3\", {{#if {{? {{getvar::hr_Language}} == 1}}}}\"다수\", or \"군중\"{{/if}}{{#if {{? {{getvar::hr_Language}} == 2}}}}\"multiple\", or \"crowd\"{{/if}}{{#if {{? {{getvar::hr_Language}} == 3}}}}\"多数\", or \"群衆\"{{/if}} are allowed when needed.\n- Within each field, multiple entries must be separated by \"/\" rather than commas.\n{{#if {{? {{getvar::hr_StartSetting}} == 1}}}}- Use a date and year appropriate to the world's setting and era.{{/if}}\n{{/if}}",
"mode": "normal",
"insertorder": 900,
"alwaysActive": true,
"secondkey": "",
"selective": false,
"folder": "folder:904eab8b-2c4f-405f-9448-0385a6e3d84b",
"bookVersion": 2
},
{
"key": "",
"comment": "📘 진행 규칙",
"content": "@@depth 0\n\n{{#if {{? {{getvar::hr_Choice_Flag}} != 0}}}}\n## Narrative Guide\n\n### Setting\n- Genre: Eroge\n\n{{#if {{? {{getvar::hr_Violation_Title}} != null}}}}#### Heroine's Current Predicament\n- Theme: {{getvar::hr_Violation_Title}}\n- Continue the scene according to this theme, maintaining the same sexually explicit predicament.\n- Avoid continuing with the same or overly similar sexual action across consecutive turns for too long; once it has been sufficiently developed, such as after ejaculation or an equivalent release event, meaningfully vary the scene instead of looping the same action, including changes to the sexual position, penetration target, stimulation focus, interaction dynamic, physical setup, or overall sexual act.{{/if}}\n\n{{/if}}",
"mode": "normal",
"insertorder": 910,
"alwaysActive": true,
"secondkey": "",
"selective": false,
"folder": "folder:904eab8b-2c4f-405f-9448-0385a6e3d84b",
"bookVersion": 2
},
{
"key": "",
"comment": "▶️ 초기 설정",
"content": "@@depth 0\n\n{{#if {{? {{getvar::hr_List_Init}} != null}}}}\n## Heroine Initial Setting\nAt the end of the narration, determine each target heroine's Character Archetype.\n\n### Target Heroines\n{{getvar::hr_List_Init}}\n\n### Format\n<Heroine Initial Setting>\n[Name: heroine name | Character Archetype: archetype]\n</Heroine Initial Setting>\n\n### Character Archetype Value Format\n- Character Archetype must be exactly one of the following: Pure, Cool, Tsundere, Tyrant, Bravado, Timid, Denpa, Broken, Hedonist.\n\n### Rules\n- Use each heroine's name exactly as written in the target list.\n- Character Archetype means the heroine's broad, stable personality archetype, not a temporary or situational mental state.\n{{/if}}",
"mode": "normal",
"insertorder": 920,
"alwaysActive": true,
"secondkey": "",
"selective": false,
"folder": "folder:904eab8b-2c4f-405f-9448-0385a6e3d84b",
"bookVersion": 2
},
{
"key": "",
"comment": "💗 흥분 체크",
"content": "@@depth 0\n\n{{#if {{? ({{getvar::hr_ArousalCheck_Flag}} == 1) && ({{getvar::hr_List_Heroine}} != null) }} }}\n## Physiological Arousal State Check\nAt the end of the narration, write records only for target heroines who are engaged in sexual activity or are clearly physiologically aroused at the end of the current response.\n\n### Target Heroines\n{{getvar::hr_List_Heroine}}\n\n### Format\n<Physiological Arousal State>\n[Name: heroine name | Arousal: level | Arousal Reception: reception]\n</Physiological Arousal State>\n\n### Arousal Level Value Format\n- Arousal must be exactly one of the following: Minimal, Low, Subtle, Mild, Clear, Sustained, Strong, Very Strong, Overwhelming, Peak\n\n### Arousal Reception Value Format\n- Arousal Reception means whether the heroine accepts or rejects the current sexual situation, including the partner, circumstances, and progression.\n- Arousal Reception must be exactly one of the following: Accepted, Rejected.\n\n### Rules\n- Determine Arousal and Arousal Reception based only on the heroine's state at the end of the current response.\n- Do not write a record merely because a heroine is present in the scene.\n- Name must be copied exactly as written in the Target Heroines list.\n- Update the Current Physiological Arousal State as it changes according to the current sexual situation.\n{{#if {{? {{getvar::hr_Language}} != 2}}}}- Arousal and Arousal Reception values must be written in English.{{/if}}\n{{/if}}",
"mode": "normal",
"insertorder": 920,
"alwaysActive": true,
"secondkey": "",
"selective": false,
"folder": "folder:904eab8b-2c4f-405f-9448-0385a6e3d84b",
"bookVersion": 2
},
{
"key": "",
"comment": "🌸 첫 경험",
"content": "@@depth 0\n\n{{#if {{? {{getvar::hr_List_FirstCheck}} != null}}}}\n## Pre-Story Sexual Experience\nFor each target heroine, you must infer her likely sexual experience before the current story based on her profile, and write the result in the specified format.\n\n### Target Heroines\n{{getvar::hr_List_FirstCheck}}\n\n### Format\n<Pre-Story Sexual Experience>\n[Name: heroine name | Experiences: applicable entries only]\n</Pre-Story Sexual Experience>\n\n### Rules\n- Use each heroine's name exactly as written in the target list.\n- Infer pre-story sexual experience from the heroine's available profile, including background, personality, age, history, relationships, and past behavior.\n- The Experiences field may contain only these entries: Kiss, Oral Sex, Vaginal Sex, Anal Sex, None.\n- Separate multiple Experiences entries with commas.\n- Use None only when there is no reasonable basis to infer any listed experience.\n{{/if}}\n\n{{#if {{? {{getvar::hr_List_FirstExperience}} != null}}}}\n## First Experience Records\nAt the end of the narration, write only the corresponding records listed below for any target heroine who undergoes one of these first experiences on the current day of the narration.\n\n### Target Heroines\n{{getvar::hr_List_FirstExperience}}\n\n### Format\n<Record First Kiss>[Name: heroine name | Date: YYYY-MM-DD | Source: who | Lead-up: overview]</Record First Kiss>\n<Record First Oral>[Name: heroine name | Date: YYYY-MM-DD | Source: partner/object | Lead-up: overview]</Record First Oral>\n<Record First Vaginal>[Name: heroine name | Date: YYYY-MM-DD | Source: partner/object | Lead-up: overview]</Record First Vaginal>\n<Record First Anal>[Name: heroine name | Date: YYYY-MM-DD | Source: partner/object | Lead-up: overview]</Record First Anal>\n\n### General Rules\n- Write each record only if a target heroine undergoes the corresponding first experience on the current day of the narration; omit all others.\n- Name must be copied exactly as written in the Target Heroines list.\n- Source must briefly identify the partner, cause, or object involved.\n- Lead-up must be one concise English sentence explaining the broad circumstance, reason, or immediate lead-in that brought the target heroine to the first experience, using specific names or identifiable sources when available instead of vague labels, while avoiding the act itself, physical details, or step-by-step scene progression.\n{{#if {{? {{getvar::hr_Language}} != 2}}}}- Lead-up must be written in English.\n- Source must be written in {{#if {{? {{getvar::hr_Language}} == 1}}}}Korean{{/if}}{{#if {{? {{getvar::hr_Language}} == 3}}}}Japanese{{/if}}.\n{{/if}}\n\n#### Record First Kiss\n- If a target heroine's first kiss occurs on the current day of the narration, this record must be written.\n- Only direct contact between the target heroine's mouth, lips, or tongue and another being's mouth, lips, or tongue qualifies as a kiss; contact with any other area, such as the cheek or forehead, does NOT qualify.\n- Source must clearly state the actual kissing partner.\n\n#### Record First Oral\n- If a target heroine's mouth first directly contacts a genital, tentacle, or phallic object through licking, sucking, or oral penetration on the current day of the narration, this record must be written.\n- Contact through clothing, underwear, or any other covering does NOT qualify.\n- Source must clearly identify the partner, entity, or object involved.\n\n#### Record First Vaginal\n- If a target heroine's first vaginal penetration occurs on the current day of the narration, this record must be written.\n- This qualifies if loss of virginity is explicitly described, or if penetration occurs in a manner that would normally be understood as sufficient to count as first vaginal penetration.\n- Finger-only insertion does NOT qualify. Mere contact, external rubbing, or clearly insufficient insertion does NOT qualify.\n- Source must clearly identify the partner, entity, or object involved.\n\n#### Record First Anal\n- If a target heroine's first anal penetration occurs on the current day of the narration, this record must be written.\n- This qualifies only if anal penetration occurs in a manner that would normally be understood as first anal penetration.\n- Finger-only insertion does NOT qualify. Mere contact, external rubbing, or clearly insufficient insertion does NOT qualify.\n- Source must clearly identify the partner, entity, or object involved.\n{{/if}}",
"mode": "normal",
"insertorder": 920,
"alwaysActive": true,
"secondkey": "",
"selective": false,
"folder": "folder:904eab8b-2c4f-405f-9448-0385a6e3d84b",
"bookVersion": 2
},
{
"key": "",
"comment": "🍼 임신",
"content": "@@depth 0\n\n{{#if {{? {{getvar::hr_List_ConceptionRisk}} != null}}}}\n## Conception Risk Records\nAt the end of the narration, write the following records only for newly occurring qualifying ejaculations in this response.\n\n### Target Heroines\n{{getvar::hr_List_ConceptionRisk}}\n\n### Format\n<Record Conception Risk>[Name: heroine name | Count: number]</Record Conception Risk>\n\n### General Rules\n- Write records only for heroines listed in Target Heroines.\n- Name must be copied exactly as written in the Target Heroines list.\n- Count must be written as digits only (e.g., 1, 2).\n- A qualifying ejaculation is semen ejaculated directly into the heroine's vagina or uterus without condom or other barrier protection in this response; ejaculation into a condom, onto external genitalia, or anywhere outside the vaginal canal does not count.\n- Count only newly occurring qualifying ejaculations in this response; do not include anything that occurred only in previous responses, and if none occur in this response, omit this entry entirely.\n{{/if}}\n\n{{#if {{? {{getvar::hr_List_Contraception}} != null}}}}\n## Emergency Contraception Records\nAt the end of the narration, write the following records only for newly occurring emergency contraception used in this response.\n\n### Target Heroines\n{{getvar::hr_List_Contraception}}\n\n### Format\n<Emergency Contraception>[Name: heroine name | Contraceptive Effect: effectiveness]</Emergency Contraception>\n\n### General Rules\n- Write records only for heroines listed in Target Heroines.\n- Name must be copied exactly as written in the Target Heroines list.\n- Contraceptive Effect must be one of the following values only: Very Low, Low, Medium, High, Very High.\n- Emergency contraception includes only contraceptive medicine, medical treatment, magic, or technology; removing semen, washing, or physical extraction does not count.\n- Count only emergency contraception newly administered or used in this response. Do not include contraception used in previous responses for any reason, even if its effects are still ongoing or described as continuing.\n{{#if {{? {{getvar::hr_Language}} != 2}}}}- Contraceptive Effect must be written in English only.{{/if}}\n{{/if}}\n\n## Pregnancy Start Records\n{{#if {{? {{getvar::hr_List_PregnancyStart}} != null}}}}At the end of the narration, always write pregnancy start records for all heroines listed in Confirmed Pregnant Heroines, and write pregnancy start records for each heroine who is established by the narration as currently pregnant.{{/if}}{{#if {{? {{getvar::hr_List_PregnancyStart}} == null}}}}At the end of the narration, write pregnancy start records for each heroine who is established by the narration as currently pregnant.{{/if}}\n\n{{#if {{? {{getvar::hr_List_PregnancyStart}} != null}}}}\n### Confirmed Pregnant Heroines\n{{getvar::hr_List_PregnancyStart}}\n{{/if}}\n\n### Format\n<Pregnancy Start>[Name: heroine name | Pregnancy Date: YYYY-MM-DD | Father: name | Days Total: number | Statement: sentence]</Pregnancy Start>\n\n### Heroine Handling Rules\n- Heroines may suspect pregnancy, but they generally do not know or confirm it immediately.\n{{#if {{? {{getvar::hr_List_PregnancyStart}} != null}}}}- Listed heroines do not need to appear or be mentioned in the narration.{{/if}}\n\n### General Rules\n{{#if {{? {{getvar::hr_List_PregnancyStart}} != null}}}}- Output a record for every heroine listed in Confirmed Pregnant Heroines and for each heroine established by the narration as currently pregnant, including through conception, implantation, or an equivalent process.\n- Name must be copied exactly as written in the Confirmed Pregnant Heroines list when the heroine is listed there.{{/if}}{{#if {{? {{getvar::hr_List_PregnancyStart}} == null}}}}- Output a record for each heroine established by the narration as currently pregnant, including through conception, implantation, or an equivalent process.{{/if}}\n{{#if {{? {{getvar::hr_List_PregnancyEnd}} != null}}}}- Do not output Pregnancy Start records for these already-pregnant heroines: {{getvar::hr_List_PregnancyEnd}}{{/if}}\n- Do not output records for pregnancies that have already ended in the past.\n- Father must be a single individual, not a group, plural label, species, or collective description.\n- Days Total must be the total gestation length as a number only; unless otherwise established, non-human pregnancies are generally short, usually ranging from a few days to tens of days.\n- Statement must be a single concise sentence stating that Name is pregnant with Father's child (e.g., \"Jane is pregnant with John's child.\").\n{{#if {{? {{getvar::hr_Language}} != 2}}}}- Father must be written in {{#if {{? {{getvar::hr_Language}} == 1}}}}Korean{{/if}}{{#if {{? {{getvar::hr_Language}} == 3}}}}Japanese{{/if}}.\n- Statement must be written in English only.\n{{/if}}\n\n{{#if {{? ({{getvar::hr_List_PregnancyEnd}} != null) || ({{getvar::hr_List_Birth}} != null)}}}}\n## Pregnancy End Records\nAt the end of the narration, write the following records for listed pregnant heroines whose current pregnancy has ended.\n\n### Target Pregnant Heroines\n{{getvar::hr_List_PregnancyEnd}}\n{{getvar::hr_List_Birth}}\n\n### Format\n<Pregnancy End>[Name: heroine name | Pregnancy End Date: YYYY-MM-DD | Type: type | Summary: one-line overview]</Pregnancy End>\n\n### General Rules\n- Write records only for heroines listed in Target Pregnant Heroines whose current pregnancy has ended; if no listed heroine's current pregnancy has ended, omit this entry entirely.\n- Name must be copied exactly as written in the Target Pregnant Heroines list.\n- Type must be one of the following values only: Birth, Oviposition, Miscarriage, Abortion.\n Birth must be used only if the pregnancy ended through live birth.\n Oviposition must be used only if the pregnancy ended through laying eggs.\n Miscarriage must be used only if the pregnancy ended unintentionally before birth or oviposition.\n Abortion must be used only if the pregnancy was intentionally terminated before birth or oviposition.\n- Summary must be a single concise sentence explaining how the pregnancy ended and what was born, laid, lost, or removed, such as whose child, whose eggs, or what fetus or pregnancy contents were involved, without mentioning any date or time.\n{{#if {{? {{getvar::hr_Language}} != 2}}}}- Type and Summary must be written in English only.{{/if}}\n{{/if}}",
"mode": "normal",
"insertorder": 920,
"alwaysActive": true,
"secondkey": "",
"selective": false,
"folder": "folder:904eab8b-2c4f-405f-9448-0385a6e3d84b",
"bookVersion": 2
},
{
"key": "",
"comment": "💓 지속 효과",
"content": "@@depth 0\n\n{{#if {{? {{getvar::hr_SexualEffect_Owned}} != null}}}}\n## Imposed Sexual Effect\nAt the end of the response, write Imposed Sexual Effect entries only when a qualifying imposed effect is added, updated, or removed for an applicable heroine.\n\n### Applicable Heroines and Existing Effects\n{{getvar::hr_SexualEffect_Owned}}\n\n### Format\n<Imposed Sexual Effect>\n[Action: Add | Heroine Name: <heroine name> | Icon: <emoji> | Name: <name> | Type: <type> | Effect: <effect description>]\n[Action: Update | Heroine Name: <heroine name> | Icon: <emoji> | Target Name: <existing name> | Name: <new name or Unchanged> | Effect: <new effect description or Unchanged>]\n[Action: Remove | Heroine Name: <heroine name> | Icon: <emoji> | Target Name: <existing name>]\n(Use only the applicable line format. Repeat separately for each distinct imposed effect change.)\n</Imposed Sexual Effect>\n\n### Definition\n- Imposed Sexual Effect is separate from Sexual Trait and describes a completed, persistent sexualized result deliberately imposed on a heroine, not the act of imposing it, the process leading to it, or a developing precondition for it.\n- Imposed Sexual Effect is generally rare because it can only be created by a character with a specialized power capable of directly imposing a persistent effect on the heroine herself.\n- Only effects that clearly fit one of the following Types qualify:\n Hypnosis: Persistent hypnosis or mind-control effect directly imposed by a specialized power.\n Womb Crest: Persistent lower-abdominal crest with an imposed effect.\n Tattoo: Persistent body inscription with an imposed effect.\n Covenant: Persistent supernatural effect imposed by a contract.\n- It must not describe a temporary state, one-time command, drug effect, ordinary experience-based change, training, conditioning, vague corruption, ordinary submission, arousal, humiliation, dependency, memory of an event, mental or physical change, injury, relationship change, personality change, or anything that belongs better as a Sexual Trait.\n\n### Field Descriptions\n- Action: Use exactly one of the following: Add, Update, Remove.\n- Heroine Name: Must be exactly one heroine name listed before a colon in Applicable Heroines and Existing Effects; copy it exactly.\n- Icon: Exactly one emoji.\n- Target Name: For Update or Remove, use exactly one existing imposed effect name listed after that heroine's colon. If the heroine is listed with \"None\", only Add can be used for that heroine.\n- Name: Short English imposed effect name. Use \"Unchanged\" for Update if the name is not changed.\n- Type: Use exactly one of the following for Add: Hypnosis, Womb Crest, Tattoo, Covenant.\n- Effect: One concise English sentence stating only the persistent resulting condition or influence left by the effect, without cause, method, sequence, scene details, emotional interpretation, or repeated explanation. Use \"Unchanged\" for Update if the effect description is not changed.\n\n### Creation Rules\n- Use Add only when the narration clearly establishes that a qualifying Type is newly imposed, activated, inscribed, hypnotically implanted, or supernaturally bound by a covenant, and that the effect remains afterward.\n- Use Update only when the narration clearly establishes that an existing imposed effect itself is deliberately and permanently changed or renamed by its imposer, another qualified power, or a specific effect-altering event.\n- Use Remove only when the narration clearly establishes that an existing imposed effect itself is removed, broken, lifted, cleansed, nullified, or dispelled.\n- Do not add effects that are too insignificant to qualify, including minor, weak, subtle, superficial, cosmetic, or narratively insignificant effects; add only effects that can meaningfully affect the heroine's future story.\n- Add only an imposed effect whose final persistent result already exists as a concrete ongoing condition; do not add anything that merely describes progress toward, preparation for, or increased likelihood of such a result, such as induction or seeds.\n- If no qualifying imposed effect is added, updated, or removed, do not output the Imposed Sexual Effect block.\n{{#if {{? {{getvar::hr_Language}} != 2}}}}- Action, Name, Type, Effect, and Target Name must be written in English.{{/if}}\n{{/if}}",
"mode": "normal",
"insertorder": 920,
"alwaysActive": true,
"secondkey": "",
"selective": false,
"folder": "folder:904eab8b-2c4f-405f-9448-0385a6e3d84b",
"bookVersion": 2
},
{
"key": "",
"comment": "📌 이벤트 임시 요약",
"content": "@@depth 0\n\n{{#if {{? ({{getvar::hr_Record_Flag}} >= 1)}} }}\n## Event Record\nAt the end of the response, you must write exactly one {{#if {{? ({{getvar::hr_Record_Flag}} == 1)}} }}event record and exactly one event objective{{/if}}{{#if {{? ({{getvar::hr_Record_Flag}} > 1)}} }}event record{{/if}}.\n\n\n### Format\n<Event Record>[Record Type: {{#if {{? ({{getvar::hr_Record_Flag}} == 1)}} }}Event Start{{/if}}{{#if {{? ({{getvar::hr_Record_Flag}} == 11)}} }}Incapacitation{{/if}}{{#if {{? ({{getvar::hr_Record_Flag}} == 12)}} }}Transition{{/if}}{{#if {{? ({{getvar::hr_Record_Flag}} == 13)}} }}Progress{{/if}} | Summary: <summary> | Start DateTime: <start datetime>]</Event Record>\n{{#if {{? ({{getvar::hr_Record_Flag}} == 1)}} }}<Event Objective>[Objective: <objective>]</Event Objective>{{/if}}\n\n### Field Descriptions\n- Record Type: The fixed English label shown in the Format.\n- Summary: {{#if {{? ({{getvar::hr_Record_Flag}} == 1)}} }}One very brief English sentence explaining why the event began, what caused the heroine side to enter it, and the broad initial situation that resulted.{{/if}}{{#if {{? ({{getvar::hr_Record_Flag}} == 11)}} }}One very brief English sentence stating the key event development that left the heroine side incapacitated and the broad incapacitated state that resulted.{{/if}}{{#if {{? ({{getvar::hr_Record_Flag}} == 12)}} }}One very brief English sentence stating how the heroine side moved from the prior situation into their current situation and what broad state resulted from that shift.{{/if}}{{#if {{? ({{getvar::hr_Record_Flag}} == 13)}} }}One very brief English sentence stating the broad current progress of the heroine side's ongoing situation and its most important change in this response.{{/if}}\n- Start DateTime: State when the event began; use YYYY-MM-DD HH:MM if the exact time is clear, or an approximate period such as morning if it is not.\n{{#if {{? ({{getvar::hr_Record_Flag}} == 1)}} }}- Objective: State the broad positive end state {{getvar::hr_Event_Main}} is trying to reach. It must describe the resolved state of the active situation, not an action plan, an obstacle to prevent, a complication to avoid, or a method for getting through the situation.{{/if}}\n\n### Summary Rules\n- Summary must remain factual, externally observable, and broad, without emotional interpretation, thematic commentary, speculation, or a full scene retelling; avoid scene-specific physical, positional, preparatory, procedural, sequential, setting, or environmental details.\n- When Summary refers to a major sexual act or violation type, it must name the specific broad act type directly in a direct actor-act-target clause, such as \"A subjected B to C, D, and E.\" or \"A performed C on B.\"\n The act name must be a specific broad act type name (e.g., anal sex, paizuri), not a vague category, result, umbrella term, or euphemism (e.g., sexual subjugation, sexual exploitation, sexual violation, abuse, assault).\n The act reference must include only the actor, target, and broad act type name(s), and must exclude any other act-specific details, including sequence, method, setting, body-specific details, or descriptive details.\n{{#if {{? ({{getvar::hr_Record_Flag}} == 1)}} }}- Summary must identify the event-level reason or setup, not merely the first visible scene action.{{/if}}{{#if {{? ({{getvar::hr_Record_Flag}} == 11)}} }}- Summary must focus on the event development and turning point that left the heroine side incapacitated, not on why the event originally began; include any major sexual act or violation type that occurred.{{/if}}{{#if {{? ({{getvar::hr_Record_Flag}} == 12)}} }}- Summary must focus on the prior situation and the key shift into the current situation, not only what is currently happening; include any major sexual act or violation type that occurred in this response.{{/if}}\n{{#if {{? {{getvar::hr_Language}} != 2}}}}- Summary must be written in English.{{/if}}\n{{/if}}",
"mode": "normal",
"insertorder": 930,
"alwaysActive": true,
"secondkey": "",
"selective": false,
"folder": "folder:904eab8b-2c4f-405f-9448-0385a6e3d84b",
"bookVersion": 2
},
{
"key": "",
"comment": "📝 이벤트 요약",
"content": "@@depth 0\n\n{{#if {{? ({{getvar::hr_Summary_Flag}} == 1)}} }}\n## Event Summary\nYou must output an Event Summary at the end of the response.\n\n### Event Records\n{{getvar::hr_Summary_Event}}\n\n### Format\n<Event Summary>\n[Date: <date> | Heroine Name: <name> | Summary: <summary> | Brief Summary: <brief summary>]\n(Repeat the line above separately for each heroine who needs an event summary.)\n</Event Summary>\n\n### Field Descriptions\n- Date: Use YYYY-MM-DD. If the event spans multiple days, use YYYY-MM-DD ~ YYYY-MM-DD.\n- Heroine Name: Use exactly one applicable heroine name from the following list, and copy it exactly: {{getvar::hr_Summary_Target}}\n- Summary: Write 2 concise English sentences summarizing the event for that heroine.\n- Brief Summary: Write one very brief English sentence that captures the core event in a condensed form.\n{{#if {{? {{getvar::hr_Language}} != 2}}}}- Summary and Brief Summary must be written in English.{{/if}}\n\n### Summary Rules\n- Base the Summary on the full Event Records and the content of this response, not only the final moment or any single part of the event.\n- The first sentence must focus only on the cause, setup, incapacitation, and key shift shown by Event Start, Incapacitation, and Transition, not on Progress or this response, and concisely summarize how the event began, how the heroine side became incapacitated, and what resulting broad situation was established by Transition.\n- The second sentence must identify the major sexual act or violation type based on the Progress section of Event Records and the content of this response, and describe it as a direct actor-act-target sentence, such as \"A subjected B to C, D, and E.\" or \"A performed C on B.\"\n Do not derive the act reference only from the final moment or this response alone; it must include the major act types shown across the full Progress section of Event Records and also account for the content of this response.\n The act name must be a specific broad act type name (e.g., anal sex, paizuri), not a vague category, result, umbrella term, or euphemism (e.g., sexual subjugation, sexual exploitation, sexual violation, abuse, assault).\n The act reference must include only the actor, target, and distinct major broad act type name(s), merging repeated or overlapping acts and omitting minor, incidental, attempted, interrupted, or less important acts unless they are the only major act type present.\n Exclude any other act-specific details, including sequence, method, setting, body-specific details, or descriptive details.\n- Brief Summary must summarize the Summary in one shorter English sentence without adding new details.\n{{/if}}",
"mode": "normal",
"insertorder": 930,
"alwaysActive": true,
"secondkey": "",
"selective": false,
"folder": "folder:904eab8b-2c4f-405f-9448-0385a6e3d84b",
"bookVersion": 2,
"useRegex": false
},
{
"key": "",
"comment": "❣️ 성적 특성",
"content": "@@depth 0\n\n{{#if {{? ({{getvar::hr_SexualTrait_Flag}} == 1)}} }}\n## Sexual Trait Development\nAt the end of the response, write Sexual Trait Development entries for applicable heroines who had sexual experiences within the Event Sequence Scope, based on those experiences and plausible long-term development.\n\n### Applicable Heroines and Existing Sexual Traits\n{{getvar::hr_SexualTrait_Owned}}\n\n### Event Sequence Scope\n- Sequence Starting Point: {{getvar::hr_SexualTrait_Scope}}\n- Use only sexual experiences that occurred from this starting point through the current response as the basis for Sexual Trait Development; sexual elements that did not occur within this scope, such as acts, stimuli, or responses, must not be used.\n\n### Format\n<Sexual Trait Development>\n[Heroine Name: <heroine name> | Developed Trait: <trait name> | Previous Trait: <None or previous trait> | Icon: <emoji>]\n(Repeat separately for each applicable heroine who qualifies. Output at most 1 entry per heroine.)\n</Sexual Trait Development>\n\n### Definition\n- Sexual Trait is a lasting sexual change shaped by the heroine's accumulated sexual experiences, including habits, sensitivities, preferences, fixations, dependencies, conditioned associations, reflexive responses, situational tendencies, body-part-specific sensitivities, erogenous-zone fixations, stimulation-specific arousal patterns, category-specific responses, stimulus-specific responses, or similar recurring sexual patterns.\n- Sexual Trait may be reasonably inferred from her sexual experiences and long-term development, including plausible changes that could develop from those experiences even if they were not directly shown during them.\n\n### Trait Variety\n- Each heroine can have up to 5 Sexual Traits total.\n- Keep each heroine's Sexual Traits distinct from one another, developing each trait in a way that feels distinct and unique to that heroine, so her traits differentiate her from other heroines.\n\n### Trait Constraints\n- Sexual Trait must be inherently sexual in nature.\n- Sexual Trait must not be a temporary or reversible circumstance itself, including a role, status, situational vulnerability, altered state, command, condition, or similar situational state that exists only while the situation remains active; if such a circumstance is relevant, the Developed Trait must name the enduring sexual change it leaves in the heroine, not the circumstance itself.\n- Sexual Trait must not be an external or imposed identifier, including a mark, brand, tattoo, seal, stigma, ownership sign, magical mark, visible symbol, label, title, role, or designation.\n- Sexual Trait must not be a general non-sexual reaction, condition, or attitude, including fear, trauma response, disgust, distrust, hatred, hostility, avoidance, or relationship attitude.\n- Sexual Trait must not use any favorable framing, including being framed as beneficial, empowering, therapeutic, stabilizing, restorative, clarifying, adaptive, functionally useful, rationalized, self-justifying, calculated, strategic, advantageous, or practical.\n\n### Field Descriptions\n- Heroine Name: Must be exactly one heroine name listed before a colon in Applicable Heroines and Existing Sexual Traits; copy it exactly.\n- Developed Trait: A short, plain, descriptive trait label that clearly identifies the lasting sexual change and its current intensity or maturity in plain descriptive words.\n Keep Developed Trait broad and concise.\n It may use a broad category adjective, but must not use prepositional phrasing such as \"to X\"; the Developed Trait must be a standalone trait label, not a relational phrase tied to a proper noun, one-off situation, or overly specific trigger.\n For a newly created trait, the name must express a modest initial form in plain words, such as mild, early, subtle, or latent, unless the Existing Sexual Traits already support a stronger form.\n For an upgraded trait, rename the trait to keep at least one exact defining core word from the Previous Trait that identifies what the trait is about; once the trait has become established beyond its initial stage, any parenthetical or stage-like modifiers must be removed, and the trait's development must be reflected through the trait name itself.\n- Previous Trait: Default to \"None\". Use an existing trait name only when New Trait and Upgrade Rules require the Developed Trait to be an upgrade of that existing trait.\n- Icon: Exactly one emoji.\n\n### New Trait and Upgrade Rules\n- Upgrade an existing Sexual Trait only when it was a major focus within the Event Sequence Scope and the Developed Trait naturally evolves from the same core lasting sexual nature without replacing its core identity with that of a different trait; the more developed the trait is, the stronger the justification required to upgrade it.\n- If the heroine already has 5 Sexual Traits, do not create a new Sexual Trait; only upgrades are allowed.\n- An upgraded Sexual Trait must become stronger than the Previous Trait; it must not regress.\n- If a heroine has 4 or fewer Sexual Traits and the Event Sequence Scope does not justify an upgrade, create a new distinct Sexual Trait instead, prioritizing variety.\n- A new lasting change that is different in essence must be created as a separate new Sexual Trait rather than an upgrade.\n- A Sexual Trait must develop through gradual, plausible progression rather than sudden extreme change; upgraded traits must advance naturally from the previous trait without becoming abruptly extreme.\n\n### Creation Rules\n- Consider each listed heroine's sexual experiences within the Event Sequence Scope as a whole, including relevant prior responses within the Event Sequence Scope when they exist, and treat the Sexual Traits most influenced by those experiences as candidates rather than automatically limiting the trait to only the final moment or the current response.\n- For each listed heroine who had any sexual experience within the Event Sequence Scope, output exactly 1 Sexual Trait Development entry unless she already has 5 Sexual Traits and no valid upgrade is justified.\n{{#if {{? {{getvar::hr_Language}} != 2}}}}- Field names and the Developed Trait value must remain written in English.{{/if}}\n{{/if}}",
"mode": "normal",
"insertorder": 940,
"alwaysActive": true,
"secondkey": "",
"selective": false,
"folder": "folder:904eab8b-2c4f-405f-9448-0385a6e3d84b",
"bookVersion": 2
},
{
"key": "",
"comment": "🧭 선택지 생성",
"content": "@@depth 0\n\n{{#if {{? ({{getvar::hr_Choice_Flag}} == 2) || ({{getvar::hr_Choice_Flag}} == 3)}} }}\n{{#if {{? ({{getvar::hr_Choice_Flag}} == 2)}} }}## Route Choice{{/if}}{{#if {{? ({{getvar::hr_Choice_Flag}} == 3)}} }}## H Scene Route Choice{{/if}}\nAt the end of the response, create exactly 5 selectable route choices for {{getvar::hr_Route_Main}}.\n\n### Format\n<Route Choice>\n[Mode: <mode> | Icon: <emoji> | Time: <time> | Location: <location> | Setup: <setup> | Type: <type>]\n(Repeat the same format exactly 5 times.)\n</Route Choice>\n\n### Field Descriptions\n- Mode: A broad scenario mode written as a simple category label (e.g., {{#if {{? ({{getvar::hr_Choice_Flag}} == 2)}} }}Combat{{/if}}{{#if {{? ({{getvar::hr_Choice_Flag}} == 3)}} }}Ambush{{/if}}).\n- Icon: Exactly one emoji representing the route.\n- Time: A short relative time gap before the route begins, such as a few hours later, the next day, a few days later, or a week later.\n- Location: A short phrase naming where the route begins.\n{{getvar::hr_Event_Outcome}}\n\n### Creation Rules\n{{#if {{? {{getvar::hr_Route_Custom}} == null}}}}- Each route must differ meaningfully in Time, Location, and Setup, so each one presents a distinct scenario rather than only extending the current immediate situation or objective.{{/if}}{{#if {{? {{getvar::hr_Route_Custom}} != null}}}}- Every choice must be meaningfully related to: {{getvar::hr_Route_Custom}}\n- Each route must differ meaningfully in Time, Location, and Setup, so each one presents a distinct scenario.{{/if}}\n- Setup must stop at the point where the route premise is established, without revealing success, failure, consequence, or later aftermath.\n{{#if {{? ({{getvar::hr_Choice_Flag}} == 2)}} }}- Each choice must be playable as the beginning of a new active situation, not a summary, aftermath, or resolved result.\n- The route should carry meaningful risk: failure, misjudgment, disadvantage, or loss of control could leave {{getvar::hr_Route_Main}} exposed to sexual exploitation or sexual violation.\n- No Setup should be so safe or ally-controlled that sexual exploitation or sexual violation seems impossible; it must include some element that makes such risk plausible.{{/if}}{{#if {{? ({{getvar::hr_Choice_Flag}} == 3)}} }}- Each choice must be playable as the beginning of a new vulnerable active situation, not a summary, aftermath, or resolved result.\n- Every choice must be a concrete vulnerable situation involving sexualized threat, exploitative intent, or coercive pressure that exposes {{getvar::hr_Route_Main}} to sexual exploitation or sexual violation and naturally leads into an H scene.{{/if}}\n- {{getvar::hr_Route_Main}} may be alone or accompanied; if accompanied, Setup should reflect who is with her.\n- Until a route is selected, it does not become part of the story and must not be mentioned outside the <Route Choice> block.\n{{#if {{? {{getvar::hr_Language}} != 2}}}}- Mode, Time, Location, and Setup must be written in {{#if {{? {{getvar::hr_Language}} == 1}}}}Korean{{/if}}{{#if {{? {{getvar::hr_Language}} == 3}}}}Japanese{{/if}}.\n- Field names and Type must remain in English.{{/if}}\n{{/if}}",
"mode": "normal",
"insertorder": 950,
"alwaysActive": true,
"secondkey": "",
"selective": false,
"folder": "folder:904eab8b-2c4f-405f-9448-0385a6e3d84b",
"bookVersion": 2
},
{
"key": "",
"comment": "🎬 이벤트 선택지",
"content": "@@depth 0\n\n{{#if {{? ({{getvar::hr_Choice_Flag}} == 1)}} }}\n## Event Development Choice\nAt the end of every response, create exactly 2 event developments and exactly 6 selectable choices for {{getvar::hr_Event_Main}}.\n\n### Format\n<Event Development>\n[Order: <order> | Source: <source> | Icon: <emoji> | Development: <event development>]\n(Repeat the same format exactly 2 times.)\n</Event Development>\n\n<Heroine Attempt Choice>\n[Order: <order> | Attempt: <attempt> | Icon: <emoji> | Outcome Type: <outcome type> | Result: <concise resulting situation>]\n(Repeat the same format exactly 6 times total: exactly 3 times for Order 1 and exactly 3 times for Order 2.)\n</Heroine Attempt Choice>\n\n<Heroine Side Action>\n[Actor: <actor> | Action: <action> | Icon: <emoji>]\n(Repeat up to 2 times.)\n</Heroine Side Action>\n\n### Creation Rules\n{{#if {{? {{getvar::hr_Event_Objective}} != null}}}}- Heroine Side Objective: {{getvar::hr_Event_Objective}}{{/if}}\n- The two Event Developments occur in order. Order 1 happens first, then Order 2 follows. Order 1 and Order 2 may share the same Source when appropriate.\n- Each Heroine Attempt Choice must be {{getvar::hr_Event_Main}}'s response to the matching Order's Event Development.\n- Make all 6 Attempts meaningfully different from one another, avoiding simple binary patterns.\n- Depending on the situation and Source, an Attempt that appears favorable, such as promising, decisive, or assertive action, may still lead to a negative Outcome Type, while an Attempt that appears difficult, risky, or indirect may still lead to a positive Outcome Type.\n- If heroine-side characters other than {{getvar::hr_Event_Main}} are actively participating in this active event, include their immediate actions in <Heroine Side Action>.\n{{#if {{? {{getvar::hr_Language}} != 2}}}}- Source, Development, Attempt, and Result must be written in {{#if {{? {{getvar::hr_Language}} == 1}}}}Korean{{/if}}{{#if {{? {{getvar::hr_Language}} == 3}}}}Japanese{{/if}}.\n- Outcome Type must always be written in English.\n{{/if}}\n\n### Heroine Attempt Constraints\n- Every Attempt must look like a competent and plausible action meant to improve the heroine side's situation, even when its hidden Outcome Type is negative.\n- Attempt must strictly describe only the intended action itself, as if completely blind to the subsequent Outcome Type and Result, without any framing modifier, judgment, or contextual detail that could imply how the action will turn out, such as trusting, arrogantly, recklessly, failing to notice, already caught, or barely.\n- Attempt must stop at the intended action itself before its outcome, and must not state or imply success, failure, consequence, outcome setup, tactical evaluation, resolved prior event, changed circumstance, or anything else that could reveal or imply what will happen, so the hidden Outcome Type and Result cannot be inferred from the Attempt.\n Wrong Example (Attempt includes consequence): \"Tries to dodge, but slips and falls.\"\n Wrong Example (Attempt frames the heroine as vulnerable): \"Focuses only on stopping the immediate threat.\"\n Wrong Example (Attempt includes tactical evaluation): \"Tries to block the overwhelming attack head-on.\"\n Right Example (neutral wording with attempt only): \"Steps forward to draw the opponent's attention.\"\n\n### Field Descriptions\n- Order: Must be exactly 1 or 2 and pairs each Heroine Attempt Choice with the Event Development of the same Order.\n- Icon: Exactly one emoji representing the development or choice.\n\n#### Event Development\n- Source: A living being, group, or non-living element, such as the environment or situation, that drives this active event development.\n- Development: A short phrase describing what is actively developing within the event; it is not inevitable, and the matching choices may change how it unfolds.\n\n#### Heroine Attempt Choice\n- Attempt: A single, concise sentence describing only {{getvar::hr_Event_Main}}'s intended immediate attempt before any outcome is shown.\n- Outcome Type: The consequence type brought about by the Attempt, not {{getvar::hr_Event_Main}}'s intent or attitude. Must be one of the following:\n{{getvar::hr_Event_Outcome}}\n- Result: A concise phrase describing how the Attempt causes the matching Development to change or resolve.\n\n#### Heroine Side Action\n- Actor: A heroine-side participant other than {{getvar::hr_Event_Main}}, written as a single character, comma-separated characters, or a collective group.\n- Action: A concise immediate action by Actor within this active event.\n{{/if}}",
"mode": "normal",
"insertorder": 950,
"alwaysActive": true,
"secondkey": "",
"selective": false,
"folder": "folder:904eab8b-2c4f-405f-9448-0385a6e3d84b",
"bookVersion": 2
},
{
"key": "",
"comment": "💦 자위 선택지",
"content": "@@depth 0\n\n{{#if {{? ({{getvar::hr_Choice_Flag}} == 21)}} }}\n## Heroine Masturbation Choice Setting\nAt the end of every response, create exactly 5 selectable masturbation choices for {{getvar::hr_Event_Main}}.\n\n### Format\n<Heroine Masturbation Choice>\n[Action: <heroine action> | Icon: <emoji> | Focus: <focus>]\n(Repeat the same format exactly 5 times.)\n</Heroine Masturbation Choice>\n\n### Creation Rules\n- Actions must fit the heroine's established characterization and current condition, including personality, sexual knowledge, sexual values, sexual preferences, kinks, openness or reserve, emotional state, lewdness, current situation, and anything else that may be relevant.\n- Each choice must be a decisive masturbation action that can directly bring the heroine to climax; it must be intense, explicit, and fully committed, not light, weak, tentative, preparatory, or merely teasing.\n- Use varied methods of direct masturbation, including the heroine's own body, sex toys, and suitable nearby items.\n- Vary the Actions and Focuses across the 5 choices, avoiding repeated methods or erogenous zones when suitable.\n{{#if {{? {{getvar::hr_Masturbation_Vaginal}} == 1}}}}- At least one Action must involve vaginal insertion. If a usable insertable item is available, it must be used rather than fingers alone; if none is available, fingers must be used.{{/if}}\n{{#if {{? {{getvar::hr_Masturbation_Anal}} == 1}}}}- At least one Action must involve anal insertion. If a usable insertable item is available, it must be used rather than fingers alone; if none is available, fingers must be used.{{/if}}\n{{#if {{? {{getvar::hr_Language}} != 2}}}}- Action and Focus must be written in {{#if {{? {{getvar::hr_Language}} == 1}}}}Korean{{/if}}{{#if {{? {{getvar::hr_Language}} == 3}}}}Japanese{{/if}}.{{/if}}\n\n### Field Descriptions\n- Action: A concise sentence clearly describing the heroine's immediate masturbation or direct sexual self-stimulation, not vague calming or non-sexual touching.\n- Icon: Exactly one emoji\n- Focus: A short noun-label for the main erogenous zone directly stimulated by the Action (e.g., breasts, clitoris, vagina, anus, mouth).\n{{/if}}",
"mode": "normal",
"insertorder": 950,
"alwaysActive": true,
"secondkey": "",
"selective": false,
"folder": "folder:904eab8b-2c4f-405f-9448-0385a6e3d84b",
"bookVersion": 2
},
{
"key": "",
"comment": "🏳️ H씬 루트 선택지",
"content": "@@depth 0\n\n{{#if {{? ({{getvar::hr_Choice_Flag}} == 101)}} }}\n## H Scene Choice\nAt the end of every response, create exactly 3 H Scene choices for {{getvar::hr_Event_Main}}.\n\n### Format\n<H Scene Choice>\n[Heroine Name: {{getvar::hr_Event_Main}} | Title: <event title> | Icon: <emoji> | Location: <location> | Setup: <setup>]\n(Repeat the same format exactly 3 times.)\n</H Scene Choice>\n\n### Creation Rules\n- Each choice must present a clearly different opening setup in which the current opposing side is the acting subject who directly places {{getvar::hr_Event_Main}} into exploitation, violation, or victimization.\n- the current opposing side must remain in control of the event premise and must not be replaced by an independent third party.\n- Even when the same opposing side or its directly connected parties are involved, each choice's Setup must establish a different route or premise that can branch into a clearly different kind of situation, not merely a different specific act performed.\n- Each Setup must describe only the event's opening setup and the situation or threat that immediately follows from it, without confirming later developments, resolved states, or completed aftermath.\n- The event setup must begin immediately after the triggering event, or immediately after {{getvar::hr_Event_Main}} is taken somewhere else by the current opposing side as a direct result of the previous scene outcome.\n- Never imply escape, rescue, release, interruption, outside help, or any chance to get away from the current opposing side.\n{{#if {{? {{getvar::hr_Language}} != 2}}}}- Title, Location, and Setup must be written in {{#if {{? {{getvar::hr_Language}} == 1}}}}Korean{{/if}}{{#if {{? {{getvar::hr_Language}} == 3}}}}Japanese{{/if}}.{{/if}}\n\n### Field Descriptions\n- Title: A short, title-like phrase that suggests the theme or premise of the H Scene.\n- Icon: Exactly one emoji that matches the theme or premise of the H Scene.\n- Location: A short phrase naming where this H Scene route actually begins, either at the current place or after being moved elsewhere.\n- Setup: A short phrase describing only the opening setup and immediate threat at the start of the H Scene, without confirming what happens afterward.\n{{/if}}",
"mode": "normal",
"insertorder": 950,
"alwaysActive": true,
"secondkey": "",
"selective": false,
"folder": "folder:904eab8b-2c4f-405f-9448-0385a6e3d84b",
"bookVersion": 2
},
{
"key": "",
"comment": "🔞 H씬 액션 선택지",
"content": "@@depth 0\n\n{{#if {{? ({{getvar::hr_Choice_Flag}} == 102)}} }}\n## Violation Choice\nAt the end of every response, create exactly 2 violation threats targeting {{getvar::hr_Event_Main}} and exactly 6 selectable heroine violation choices for {{getvar::hr_Event_Main}}.\n\n### Format\n<Violation Threat>\n[Order: <order> | Actor: <actor> | Icon: <emoji> | Intent: <intent> | Intent Sexual Act: <major sexual act> | Intent Focus: <target erogenous focus>]\n(Repeat the same format exactly 2 times.)\n</Violation Threat>\n\n<Heroine Violation Choice>\n[Order: <order> | Action: <heroine action> | Icon: <emoji> | Path: <Continuation or Redirection or Backfire> | Resulting Sexual Act: <resulting major sexual act> | Resulting Focus: <resulting erogenous focus>]\n(Repeat the same format exactly 6 times total: exactly 3 times for Order 1 and exactly 3 times for Order 2.)\n</Heroine Violation Choice>\n\n### Creation Rules\n- The two Violation Threats occur in order. Order 1 happens first, then Order 2 follows. Order 1 and Order 2 may share the same Actor when appropriate.\n- Each Heroine Violation Choice must be the heroine's response to the matching Order's Violation Threat.\n- The heroine's Actions must reflect both her established characterization and current condition, including personality, values, prior sexual experience, sexual attitudes, openness or reserve, emotional state, and any active physical, mental, magical, chemical, or coercive influences. Make all 6 heroine Actions meaningfully different from one another, avoiding simple binary patterns.\n- Path defines the outcome, not the heroine's intent or attitude. For example, Continuation does not have to imply compliance, Redirection does not have to imply refusal, and Backfire does not have to imply strong resistance. Any Action may lead to any Path depending on the Actor and situation, including outcomes that differ from, complicate, or reverse the heroine's apparent aim.\n- The Intent must not be treated as inevitable. For each Order, at least one choice must shift the Resulting Focus to an entirely separate body region or interaction channel.\n- If Resulting Sexual Act differs from the matching Intent Sexual Act, it must not represent prevention, escape, or de-escalation; it must become a comparably explicit sexual act, not a reduced substitute or lower-intensity contact. For example, penetration-based acts should shift to penetration through a different orifice rather than peripheral contact.\n{{#if {{? {{getvar::hr_Language}} != 2}}}}- Actor, Intent, Action, Intent Sexual Act, Intent Focus, Resulting Sexual Act, and Resulting Focus must be written in {{#if {{? {{getvar::hr_Language}} == 1}}}}Korean{{/if}}{{#if {{? {{getvar::hr_Language}} == 3}}}}Japanese{{/if}}.{{/if}}\n\n### Field Descriptions\n- Order: Must be exactly 1 or 2 and pairs each Heroine Violation Choice with the Violation Threat of the same Order.\n- Icon: Exactly one emoji representing the choice.\n\n#### Violation Threat\n- Actor: An actor who is attempting to sexually violate or exploit the heroine.\n- Intent: A short phrase that clearly reflects the actor's intent for a direct and explicit sexual act involving the heroine, including acts the actor wants to perform, receive from her, or direct her to perform.\n- Intent Sexual Act: A short noun-label for the primary sexual act in the actor's Intent.\n- Intent Focus: A short noun-label for the main erogenous focus in the actor's Intent.\n\n#### Heroine Violation Choice\n- Action: A single, concise sentence describing only the heroine's immediate attempt before any result occurs. It must not reveal or imply the later Path, Resulting Sexual Act, Resulting Focus, or whether the outcome becomes favorable or adverse for her.\n Even if Backfire, Action must not include or imply the Backfire result, such as \"tries to..., only to...\"\n Wrong Example (Action reveals the outcome): \"She avoids his touch, but ends up guiding his hand to her chest.\"\n Right Example (attempt only): \"She raises her arm to block his hand.\"\n- Path: Must be one of Continuation, Redirection, or Backfire. For each Order, use each Path exactly once.\n If Path is Continuation, Resulting Sexual Act and Resulting Focus may match the matching Intent Sexual Act and Intent Focus, even if the heroine's Action was not meant to allow it.\n If Path is Redirection, Resulting Focus must shift to a clearly separate body region or interaction channel, not an adjacent part or the same anatomical system. Resulting Sexual Act may change to fit the redirection.\n If Path is Backfire, the heroine's Action must cause an unintended or unexpected countereffect, and the Resulting Sexual Act must change into something more intense, more invasive, more exposing, or more compromising than the actor's original Intent.\n- Resulting Sexual Act: A short noun-label for the primary sexual act that becomes central after the heroine's Action and the actors' immediate response.\n- Resulting Focus: A short noun-label for the main erogenous focus that becomes central after the heroine's Action and the actors' immediate response.\n{{/if}}",
"mode": "normal",
"insertorder": 950,
"alwaysActive": true,
"secondkey": "",
"selective": false,
"folder": "folder:904eab8b-2c4f-405f-9448-0385a6e3d84b",
"bookVersion": 2
},
{
"key": "",
"comment": "🏃♀️ H씬 이후 선택지",
"content": "@@depth 0\n\n{{#if {{? ({{getvar::hr_Choice_Flag}} == 103)}} }}\n## Fate Choice\nAt the end of the response, create exactly 5 choices for the immediate fate of {{getvar::hr_Event_Main}}.\n\n### Format\n<Fate Choice>\n[Heroine Name: {{getvar::hr_Event_Main}} | Type: <type> | Icon: <emoji> | Time: <time> | Location: <location> | Situation: <situation>]\n(Repeat the same format exactly 5 times.)\n</Fate Choice>\n\n### Creation Rules\n- For Detention and Transfer, Situation may include what she experienced during the time gap, but must end with her in a temporarily settled state after the immediate ordeal has paused or shifted, not during an ongoing act or unresolved climax.\n{{#if {{? {{getvar::hr_Language}} != 2}}}}- Time, Location, and Situation must be written in {{#if {{? {{getvar::hr_Language}} == 1}}}}Korean{{/if}}{{#if {{? {{getvar::hr_Language}} == 3}}}}Japanese{{/if}}.{{/if}}\n\n### Type Descriptions\n- Detention: She remains held by the same opposing side.\n- Transfer: She remains held, but is moved, handed over, sold, or placed under a new holder.\n- Rescue: She is saved by outside intervention.\n- Release: She is released or abandoned.\n- Escape: She escapes by herself.\n\n### Field Descriptions\n- Type: Must be exactly one of Detention, Transfer, Rescue, Release, or Escape. Use each Type exactly once.\n- Icon: Exactly one emoji.\n- Time: A short relative time gap before the next situation begins, such as a few hours later, the next day, a few days later, or a week later.\n- Location: A short phrase naming where the heroine is now.\n- Situation: A short phrase describing what happened during the time gap and what state the heroine is in now.\n{{/if}}",
"mode": "normal",
"insertorder": 950,
"alwaysActive": true,
"secondkey": "",
"selective": false,
"folder": "folder:904eab8b-2c4f-405f-9448-0385a6e3d84b",
"bookVersion": 2
},
{
"key": "",
"comment": "성적특성백업",
"content": "- Add only an imposed effect whose final persistent result already exists as a concrete ongoing condition; do not add anything that merely describes progress toward, preparation for, or increased likelihood of such a result, such as induction or seeds.",
"mode": "normal",
"insertorder": 100,
"alwaysActive": false,
"secondkey": "",
"selective": false,
"useRegex": false,
"bookVersion": 2
}
],
"assets": [],
"customModuleToggle": ""
}