UNPKG

kangjun-story-mcp

Version:

Kangjun Story MCP: Novel writing assistant MCP server with character and RPG equipment tools

571 lines (570 loc) 24.3 kB
// Advanced Story Tools - Phase 2 기능들 // 작가 스타일 에뮬레이션, 브레인스토밍, 다중 버전 생성, 심화 검사 import { mulberry32, pick } from "./utils.js"; export class AdvancedStoryTools { // 미리 정의된 작가 스타일들 predefinedStyles = { hemingway: { name: "어니스트 헤밍웨이", sentenceLength: "short", vocabulary: "simple", tone: "serious", pacing: "moderate", description: "minimal", dialogue: "heavy", characteristics: ["간결한 문체", "빙산 이론", "객관적 서술", "대화 중심"] }, tolkien: { name: "J.R.R. 톨킨", sentenceLength: "long", vocabulary: "literary", tone: "serious", pacing: "slow", description: "verbose", dialogue: "light", characteristics: ["세밀한 세계관", "고어체 사용", "시적 표현", "역사적 깊이"] }, murakami: { name: "무라카미 하루키", sentenceLength: "varied", vocabulary: "moderate", tone: "philosophical", pacing: "slow", description: "balanced", dialogue: "balanced", characteristics: ["초현실적 요소", "일상적 디테일", "고독한 톤", "음악과 문학 인용"] }, king: { name: "스티븐 킹", sentenceLength: "varied", vocabulary: "moderate", tone: "dark", pacing: "dynamic", description: "rich", dialogue: "heavy", characteristics: ["공포와 서스펜스", "일상 속 공포", "캐릭터 중심", "구어체"] }, rowling: { name: "J.K. 롤링", sentenceLength: "medium", vocabulary: "moderate", tone: "light", pacing: "moderate", description: "balanced", dialogue: "heavy", characteristics: ["상상력", "유머", "성장 서사", "디테일한 마법 세계"] }, christie: { name: "아가사 크리스티", sentenceLength: "medium", vocabulary: "moderate", tone: "serious", pacing: "moderate", description: "balanced", dialogue: "heavy", characteristics: ["논리적 구성", "단서 배치", "반전", "캐릭터 심리"] } }; // 작가 스타일 에뮬레이션 emulateWriterStyle(text, styleName, customStyle) { const style = customStyle || this.predefinedStyles[styleName.toLowerCase()]; if (!style) { throw new Error(`스타일 '${styleName}'을 찾을 수 없습니다. 사용 가능: ${Object.keys(this.predefinedStyles).join(", ")}`); } let styledText = text; const techniques = []; // 문장 길이 조정 if (style.sentenceLength === "short") { styledText = this.shortenSentences(styledText); techniques.push("짧은 문장으로 분할"); } else if (style.sentenceLength === "long") { styledText = this.combineSentences(styledText); techniques.push("문장 연결 및 확장"); } // 어휘 수준 조정 if (style.vocabulary === "simple") { techniques.push("간단한 어휘 사용"); } else if (style.vocabulary === "literary") { styledText = this.addLiteraryVocabulary(styledText); techniques.push("문학적 어휘 추가"); } // 톤 적용 const toneMarkers = this.applyTone(style.tone); techniques.push(`${style.tone} 톤 적용: ${toneMarkers}`); // 묘사 수준 조정 if (style.description === "minimal") { techniques.push("최소한의 묘사"); } else if (style.description === "rich" || style.description === "verbose") { styledText = this.enrichDescription(styledText); techniques.push("풍부한 묘사 추가"); } // 특징 추가 if (style.characteristics) { techniques.push(...style.characteristics.map(c => `특징: ${c}`)); } return { original: text, styled: styledText, styleApplied: style, techniques }; } // 브레인스토밍 brainstorm(options) { const rng = mulberry32(options.seed || Date.now()); const quantity = options.quantity || 5; const ideas = []; const templates = { character: { prefixes: ["고독한", "비밀스러운", "잊혀진", "배신당한", "선택받은", "저주받은", "떠도는"], cores: ["전사", "학자", "상인", "예술가", "탐정", "의사", "도둑", "왕족", "예언자"], suffixes: ["과거를 숨긴", "복수를 꿈꾸는", "진실을 찾는", "운명을 거부하는", "사랑을 잃은"] }, plot: { starts: ["예상치 못한 발견", "갑작스런 실종", "비밀 편지", "이상한 꿈", "기억 상실"], conflicts: ["숨겨진 정체", "금지된 사랑", "가족의 비밀", "배신과 음모", "시간 제한"], resolutions: ["희생을 통한 구원", "진실의 폭로", "화해와 용서", "새로운 시작", "순환의 끝"] }, setting: { times: ["먼 미래", "대체 역사", "평행 세계", "시간이 멈춘", "과거와 현재가 공존하는"], places: ["떠다니는 도시", "지하 왕국", "경계의 땅", "잊혀진 섬", "꿈의 세계"], atmospheres: ["안개로 덮인", "영원한 황혼의", "시간이 뒤틀린", "마법이 죽어가는", "기계가 지배하는"] }, conflict: { internal: ["신념 vs 현실", "사랑 vs 의무", "복수 vs 용서", "자유 vs 안전", "진실 vs 평화"], external: ["인간 vs 자연", "개인 vs 사회", "과거 vs 미래", "마법 vs 과학", "질서 vs 혼돈"], stakes: ["세계의 운명", "사랑하는 이의 생명", "잃어버린 기억", "영원한 저주", "최후의 선택"] }, theme: { universal: ["사랑의 힘", "희생의 가치", "정체성 탐구", "권력의 부패", "운명과 자유의지"], philosophical: ["선과 악의 경계", "진실의 상대성", "시간의 의미", "존재의 이유", "기억과 정체성"], social: ["계급 갈등", "전통 vs 변화", "개인 vs 집단", "정의의 실현", "차별과 평등"] }, title: { patterns: [ "{형용사} {명사}", "{명사}의 {명사}", "{동사}하는 {명사}", "{시간} {장소}", "{명사}와 {명사}", "마지막 {명사}", "{색깔} {명사}의 {명사}" ], elements: { 형용사: ["잃어버린", "마지막", "숨겨진", "저주받은", "잊혀진", "깨어난", "떠도는"], 명사: ["기억", "약속", "비밀", "그림자", "빛", "문", "길", "꿈", "진실", "운명"], 동사: ["잠든", "떠나는", "돌아온", "기다리는", "찾아가는", "사라진"], 시간: ["새벽", "황혼", "한밤중", "일곱번째 날", "천년 후"], 장소: ["정원", "탑", "숲", "바다", "하늘", "경계"], 색깔: ["붉은", "검은", "하얀", "황금빛", "은빛", "푸른"] } } }; if (options.type === "title") { // 제목 생성 로직 const titleTemplate = templates.title; for (let i = 0; i < quantity; i++) { const pattern = pick(rng, titleTemplate.patterns); let title = pattern; for (const [key, values] of Object.entries(titleTemplate.elements)) { if (title.includes(`{${key}}`)) { title = title.replace(`{${key}}`, pick(rng, values)); } } ideas.push(title); } } else { // 다른 타입의 아이디어 생성 const template = templates[options.type]; const keys = Object.keys(template); for (let i = 0; i < quantity; i++) { const parts = keys.map(key => pick(rng, template[key])); ideas.push(parts.join(" - ")); } } // 아이디어 간 연결 찾기 const connections = []; for (let i = 0; i < Math.min(3, ideas.length - 1); i++) { const relations = ["와 대립하는", "를 보완하는", "로 이어지는", "와 충돌하는", "를 심화시키는"]; connections.push({ idea1: ideas[i], idea2: ideas[i + 1], relation: pick(rng, relations) }); } // 최적 조합 제안 let bestCombination = undefined; if (ideas.length >= 2) { bestCombination = `"${ideas[0]}"과 "${ideas[1]}"을 결합: ${connections[0]?.relation || "연결하여"} 더 깊은 서사 구축`; } return { ideas, connections, bestCombination }; } // 다중 버전 생성 generateAlternatives(options) { const rng = mulberry32(options.seed || Date.now()); const variations = options.variations || 3; const alternatives = []; const variationStrategies = { scene: [ { name: "시점 변경", apply: (text) => this.changePerspective(text) }, { name: "시간 조정", apply: (text) => this.adjustTiming(text) }, { name: "감각 강조", apply: (text) => this.emphasizeSenses(text) }, { name: "감정 심화", apply: (text) => this.deepenEmotion(text) } ], dialogue: [ { name: "톤 변경", apply: (text) => this.changeTone(text) }, { name: "간접화", apply: (text) => this.makeIndirect(text) }, { name: "감정 추가", apply: (text) => this.addEmotionalCues(text) }, { name: "속도 조절", apply: (text) => this.adjustPacing(text) } ], description: [ { name: "구체화", apply: (text) => this.makeMoreSpecific(text) }, { name: "은유 추가", apply: (text) => this.addMetaphors(text) }, { name: "감각 확장", apply: (text) => this.expandSensory(text) }, { name: "분위기 강화", apply: (text) => this.enhanceMood(text) } ], opening: [ { name: "인 미디어스 레스", apply: (text) => this.startInMediasRes(text) }, { name: "대화로 시작", apply: (text) => this.startWithDialogue(text) }, { name: "묘사로 시작", apply: (text) => this.startWithDescription(text) }, { name: "질문 제기", apply: (text) => this.startWithQuestion(text) } ], ending: [ { name: "열린 결말", apply: (text) => this.makeOpenEnding(text) }, { name: "순환 구조", apply: (text) => this.makeCircular(text) }, { name: "여운 남기기", apply: (text) => this.addResonance(text) }, { name: "반전 추가", apply: (text) => this.addTwist(text) } ] }; const strategies = variationStrategies[options.type]; // 스타일 적용 버전들 if (options.styles && options.styles.length > 0) { for (let i = 0; i < Math.min(variations, options.styles.length); i++) { const style = options.styles[i]; const styled = this.applyStyleToText(options.originalText, style); alternatives.push({ version: styled, style, changes: [`${style.name} 스타일 적용`] }); } } // 전략 적용 버전들 const remainingSlots = variations - alternatives.length; for (let i = 0; i < remainingSlots && i < strategies.length; i++) { const strategy = pick(rng, strategies); const modified = strategy.apply(options.originalText); alternatives.push({ version: modified, changes: [strategy.name] }); } // 추천 const recommendation = alternatives.length > 0 ? `추천: ${alternatives[0].changes[0]} 버전 - 원문의 핵심을 유지하면서 새로운 관점 제공` : "원문 유지를 권장합니다"; return { original: options.originalText, alternatives, recommendation }; } // 심화된 연속성 검사 deepConsistencyCheck(content) { const issues = []; const strengths = []; let score = 100; // 캐릭터 일관성 검사 if (content.characters) { for (const character of content.characters) { const inconsistencies = this.checkCharacterConsistency(character, content.chapters); issues.push(...inconsistencies); score -= inconsistencies.length * 5; if (inconsistencies.length === 0) { strengths.push(`${character.name}의 캐릭터 일관성 우수`); } } } // 타임라인 검사 if (content.timeline) { const timelineIssues = this.checkTimelineConsistency(content.timeline); issues.push(...timelineIssues); score -= timelineIssues.length * 3; if (timelineIssues.length === 0) { strengths.push("시간 흐름 논리적"); } } // 세계관 규칙 검사 if (content.worldRules) { const ruleViolations = this.checkWorldRules(content.worldRules, content.chapters); issues.push(...ruleViolations); score -= ruleViolations.length * 4; if (ruleViolations.length === 0) { strengths.push("세계관 규칙 준수"); } } // 서사 구조 검사 const structureAnalysis = this.analyzeNarrativeStructure(content.chapters); if (structureAnalysis.issues.length > 0) { issues.push(...structureAnalysis.issues); score -= structureAnalysis.issues.length * 2; } else { strengths.push("안정적인 서사 구조"); } // 추천사항 생성 const recommendations = this.generateRecommendations(issues, strengths); return { score: Math.max(0, score), issues: issues.sort((a, b) => { const severityOrder = { critical: 0, major: 1, minor: 2 }; return severityOrder[a.severity] - severityOrder[b.severity]; }), strengths, recommendations }; } // === Private Helper Methods === shortenSentences(text) { // 긴 문장을 짧게 분할 return text.replace(/([^.!?]+[.!?])/g, (match) => { if (match.length > 50) { const parts = match.split(/,|\s+그리고\s+|\s+하지만\s+/); if (parts.length > 1) { return parts.map(p => p.trim() + ".").join(" "); } } return match; }); } combineSentences(text) { // 짧은 문장들을 연결 return text.replace(/([^.!?]+[.!?])\s+([^.!?]+[.!?])/g, (match, s1, s2) => { if (s1.length < 30 && s2.length < 30) { return s1.replace(/[.!?]$/, "") + ", 그리고" + s2.toLowerCase(); } return match; }); } addLiteraryVocabulary(text) { // 문학적 어휘로 대체 const replacements = { "좋은": "훌륭한", "나쁜": "저열한", "예쁜": "아름다운", "슬픈": "애잔한", "기쁜": "환희에 찬", "화난": "격분한" }; let result = text; for (const [simple, literary] of Object.entries(replacements)) { result = result.replace(new RegExp(simple, "g"), literary); } return result; } applyTone(tone) { const toneMarkers = { light: "밝고 경쾌한 어조", serious: "진지하고 무거운 어조", humorous: "유머러스하고 가벼운 어조", dark: "어둡고 음울한 어조", philosophical: "사색적이고 철학적 어조" }; return toneMarkers[tone] || tone; } enrichDescription(text) { // 간단한 묘사 확장 예시 return text.replace(/(\w+)이 있었다/g, "$1이 마치 시간이 멈춘 듯 고요히 자리하고 있었다"); } applyStyleToText(text, style) { let result = text; if (style.sentenceLength === "short") { result = this.shortenSentences(result); } else if (style.sentenceLength === "long") { result = this.combineSentences(result); } if (style.vocabulary === "literary") { result = this.addLiteraryVocabulary(result); } return result; } // 변형 전략 헬퍼들 changePerspective(text) { return "다른 시점에서: " + text; } adjustTiming(text) { return "시간을 조정하여: " + text; } emphasizeSenses(text) { return text + " (감각적 디테일 추가됨)"; } deepenEmotion(text) { return text + " (감정 심화됨)"; } changeTone(text) { return "(톤 변경) " + text; } makeIndirect(text) { return "간접적으로 표현: " + text; } addEmotionalCues(text) { return text + " (감정 단서 추가)"; } adjustPacing(text) { return "속도 조절: " + text; } makeMoreSpecific(text) { return "구체화: " + text; } addMetaphors(text) { return text + " (은유 추가)"; } expandSensory(text) { return text + " (오감 확장)"; } enhanceMood(text) { return "분위기 강화: " + text; } startInMediasRes(text) { return "[사건 한가운데로] " + text; } startWithDialogue(text) { return '"대화로 시작" - ' + text; } startWithDescription(text) { return "[풍경 묘사] " + text; } startWithQuestion(text) { return "왜 그랬을까? " + text; } makeOpenEnding(text) { return text + "... (열린 결말)"; } makeCircular(text) { return text + " [처음으로 돌아가며]"; } addResonance(text) { return text + " (여운이 남는다)"; } addTwist(text) { return text + " 그러나 모든 것은 달랐다."; } // 일관성 검사 헬퍼들 checkCharacterConsistency(character, chapters) { const issues = []; // 캐릭터 특성이 유지되는지 검사 for (const trait of character.traits) { let found = false; for (const chapter of chapters) { if (chapter.content.includes(character.name)) { found = true; // 특성과 모순되는 행동 검사 if (trait === "용감한" && chapter.content.includes(character.name + "은 도망쳤다")) { issues.push({ severity: "major", type: "character_inconsistency", location: `챕터 ${chapter.number}`, description: `${character.name}의 '${trait}' 특성과 모순되는 행동`, suggestion: "캐릭터의 행동 동기를 명확히 설명하거나 특성을 재고려" }); } } } } return issues; } checkTimelineConsistency(timeline) { const issues = []; // 시간 순서 검사 for (let i = 1; i < timeline.length; i++) { if (timeline[i].chapter < timeline[i - 1].chapter) { // 챕터 순서와 시간 순서 불일치 issues.push({ severity: "critical", type: "timeline_inconsistency", location: `챕터 ${timeline[i].chapter}`, description: `시간 역전: "${timeline[i].event}"가 이전 사건보다 먼저 표시됨`, suggestion: "타임라인 재구성 또는 플래시백 명시" }); } } return issues; } checkWorldRules(rules, chapters) { const issues = []; // 세계관 규칙 위반 검사 for (const rule of rules) { for (const chapter of chapters) { // 간단한 규칙 위반 검사 예시 if (rule.includes("마법 금지") && chapter.content.includes("마법")) { issues.push({ severity: "major", type: "world_rule_violation", location: `챕터 ${chapter.number}`, description: `세계관 규칙 위반: "${rule}"`, suggestion: "규칙 수정 또는 예외 상황 설명 추가" }); } } } return issues; } analyzeNarrativeStructure(chapters) { const issues = []; // 서사 구조 분석 (3막 구조 기준) const totalChapters = chapters.length; const act1End = Math.floor(totalChapters * 0.25); const act2End = Math.floor(totalChapters * 0.75); // 각 막의 기본 요소 검사 let hasIncitingIncident = false; let hasClimax = false; let hasResolution = false; for (const chapter of chapters) { if (chapter.number <= act1End) { if (chapter.content.includes("시작") || chapter.content.includes("발견")) { hasIncitingIncident = true; } } else if (chapter.number <= act2End) { if (chapter.content.includes("절정") || chapter.content.includes("대결")) { hasClimax = true; } } else { if (chapter.content.includes("끝") || chapter.content.includes("해결")) { hasResolution = true; } } } if (!hasIncitingIncident) { issues.push({ severity: "minor", type: "structure", location: "1막", description: "촉발 사건 부재", suggestion: "1막에 주인공을 행동하게 만드는 사건 추가" }); } return { issues }; } generateRecommendations(issues, strengths) { const recommendations = []; // 이슈 기반 추천 if (issues.some(i => i.type === "character_inconsistency")) { recommendations.push("캐릭터 프로필을 재검토하고 일관성 체크리스트 작성"); } if (issues.some(i => i.type === "timeline_inconsistency")) { recommendations.push("타임라인 차트를 작성하여 시간 흐름 시각화"); } // 강점 기반 추천 if (strengths.includes("안정적인 서사 구조")) { recommendations.push("현재 구조를 유지하며 세부 디테일 강화"); } return recommendations; } }