/** Escape for safe text nodes (not HTML injection). */ function appendText(parent, text) { if (!text) return; parent.appendChild(document.createTextNode(text)); } const PLAYER_CHOSE_RE = /^\[Player chose:\s*(.+)\]$/s; const RP_TOKEN_RE = /("(?:[^"\\]|\\.)*"|«[^»]*»|'(?:[^'\\]|\\.)*'|\*\*[^*]+\*\*|\*[^*]+\*)/g; const OUTCOME_CLASS = { 'critical failure': 'outcome-crit-fail', failure: 'outcome-fail', success: 'outcome-success', 'critical success': 'outcome-crit-success', }; export function isPlayerChoiceContent(text) { return PLAYER_CHOSE_RE.test((text || '').trim()); } export function parsePlayerChoice(text) { const m = (text || '').trim().match(PLAYER_CHOSE_RE); return m ? m[1].trim() : null; } function appendPlayerChoice(frag, label) { const wrap = document.createElement('span'); wrap.className = 'rp-choice'; const tag = document.createElement('span'); tag.className = 'rp-choice-tag'; tag.textContent = '🔘 Выбор'; wrap.appendChild(tag); appendText(wrap, ' '); const lab = document.createElement('span'); lab.className = 'rp-choice-label'; lab.textContent = label; wrap.appendChild(lab); frag.appendChild(wrap); } /** * Build DOM fragment for RP formatting (dialogue, action, player choice). */ export function buildRpFormatFragment(text) { const frag = document.createDocumentFragment(); if (!text) return frag; const trimmed = text.trim(); const choiceLabel = parsePlayerChoice(trimmed); if (choiceLabel !== null) { appendPlayerChoice(frag, choiceLabel); return frag; } let last = 0; for (const m of trimmed.matchAll(RP_TOKEN_RE)) { const idx = m.index ?? 0; if (idx > last) appendText(frag, trimmed.slice(last, idx)); const token = m[0]; if ( (token.startsWith('"') && token.endsWith('"')) || (token.startsWith('«') && token.endsWith('»')) || (token.startsWith("'") && token.endsWith("'")) ) { const span = document.createElement('span'); span.className = 'rp-dialogue'; span.textContent = token; frag.appendChild(span); } else if (token.startsWith('**') && token.endsWith('**')) { const em = document.createElement('em'); em.className = 'rp-action'; em.textContent = token.slice(2, -2); frag.appendChild(em); } else if (token.startsWith('*') && token.endsWith('*')) { const em = document.createElement('em'); em.className = 'rp-action'; em.textContent = token.slice(1, -1); frag.appendChild(em); } else { appendText(frag, token); } last = idx + token.length; } if (last < trimmed.length) appendText(frag, trimmed.slice(last)); return frag; } /** * Dice override block on user bubble (intent struck through → resolution). */ export function buildDiceOverrideFragment(resolution) { const { intent_text, resolution_text, roll, outcome } = resolution; const wrap = document.createElement('div'); wrap.className = 'dice-user-override'; const badge = document.createElement('div'); badge.className = `dice-user-badge ${OUTCOME_CLASS[outcome] || ''}`; badge.textContent = `🎲 ${roll} · ${outcome || 'roll'}`; wrap.appendChild(badge); const row = document.createElement('div'); row.className = 'dice-user-row'; const struck = document.createElement('span'); struck.className = 'dice-intent-struck'; struck.textContent = intent_text || ''; row.appendChild(struck); const arrow = document.createElement('span'); arrow.className = 'dice-intent-arrow'; arrow.textContent = '→'; row.appendChild(arrow); const resolved = document.createElement('span'); resolved.className = 'dice-intent-resolved'; resolved.textContent = resolution_text || ''; row.appendChild(resolved); wrap.appendChild(row); return wrap; } export function applyDiceOverrideToBubble(bubble, resolution) { if (!resolution?.resolution_text) return; bubble.dataset.diceOverride = '1'; const raw = bubble.dataset.raw ?? ''; const frag = document.createDocumentFragment(); if (raw && !isPlayerChoiceContent(raw)) { frag.appendChild(buildRpFormatFragment(raw)); frag.appendChild(document.createElement('br')); } else if (raw) { frag.appendChild(buildRpFormatFragment(raw)); frag.appendChild(document.createElement('br')); } frag.appendChild(buildDiceOverrideFragment(resolution)); bubble.innerHTML = ''; bubble.appendChild(frag); bubble.classList.add('rp-formatted', 'has-dice-override'); } export function applyRpFormatToBubble(bubble) { if (bubble.dataset.diceOverride === '1' && bubble._diceResolution) { applyDiceOverrideToBubble(bubble, bubble._diceResolution); return; } const raw = bubble.dataset.raw ?? bubble.textContent ?? ''; bubble.dataset.raw = raw; bubble.innerHTML = ''; bubble.appendChild(buildRpFormatFragment(raw)); bubble.classList.add('rp-formatted'); } export function applyRpPlainToBubble(bubble) { const raw = bubble.dataset.raw ?? bubble.textContent ?? ''; bubble.dataset.raw = raw; bubble.textContent = raw; bubble.classList.remove('rp-formatted'); } export function attachFormatToggle(wrapper, bubble) { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'format-btn'; btn.textContent = '↩ Текст'; let showingPlain = false; const syncBtn = () => { btn.textContent = showingPlain ? '✨' : '↩ Текст'; btn.title = showingPlain ? 'Форматировать' : 'Показать оригинал'; }; btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); if (showingPlain) { if (bubble._diceResolution) { applyDiceOverrideToBubble(bubble, bubble._diceResolution); } else { applyRpFormatToBubble(bubble); } showingPlain = false; } else { applyRpPlainToBubble(bubble); showingPlain = true; } syncBtn(); }); syncBtn(); wrapper.appendChild(btn); return btn; } export function initBubbleContent(bubble, rawText, { formatted = true, actionResolution = null } = {}) { bubble.dataset.raw = rawText ?? ''; bubble._diceResolution = actionResolution || null; if (actionResolution?.resolution_text && formatted) { bubble.dataset.diceOverride = '1'; applyDiceOverrideToBubble(bubble, actionResolution); return; } if (formatted && rawText) { applyRpFormatToBubble(bubble); } else { bubble.textContent = rawText ?? ''; bubble.classList.remove('rp-formatted', 'has-dice-override'); } }