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
JavaScript
// 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;
}
}