블로그로 돌아가기

AI 인터랙티브 스토리의 호감도 시스템 설계: 행동 가이드, 이벤트 분기, 상태 복원

Game MechanicsAI PipelineState MachineNestJSPrompt Engineering

도입

AI 캐릭터와 대화하는 것만으로는 "인터랙티브 스토리"가 되지 않는다. 사용자의 선택이 캐릭터의 태도를 바꾸고, 바뀐 관계가 이벤트를 트리거하고, 트리거된 이벤트가 스토리를 분기시키는 — 이 연쇄가 있어야 비로소 "내가 만들어가는 이야기"가 된다.

Plit의 스토리 모드에서 이 연쇄의 핵심은 호감도(Affinity) 시스템이다. 캐릭터별 0~100의 점수가 매 대화마다 AI에 의해 평가되고, 그 점수가 캐릭터의 말투, 신체 접촉 허용 범위, 이벤트 분기를 모두 결정한다.

이 글에서는 호감도 시스템의 다섯 가지 핵심 설계 — 행동 가이드, AI 평가, 이벤트 상태머신, 신체 접촉 규칙, 되감기 — 를 실제 코드 기반으로 기록한다.


핵심 1: 7단계 호감도 행동 가이드

호감도 점수는 그 자체로는 숫자에 불과하다. 이 숫자가 AI의 행동으로 변환되려면 단계별 행동 가이드가 필요하다.

getAffinityBehaviorGuide()는 현재 호감도를 받아 캐릭터의 태도 지침을 반환한다. 이 텍스트가 AI 응답 생성 프롬프트에 주입되어, 호감도에 따라 캐릭터의 말투와 행동이 달라진다.

// game-rules.ts — 호감도별 행동 가이드
export function getAffinityBehaviorGuide(score: number): string {
  if (score <= 15) {
    return '경계/무관심. 대화에 최소한으로 응하지만 적대적이진 않다. 시선을 피하고 거리를 유지한다.';
  } else if (score <= 30) {
    return '쿨한 거리두기. 예의는 지키되 관심 없다는 태도. 필요한 말만 하고 벽을 세운다.';
  } else if (score <= 45) {
    return '중립~미약한 관심. 대화에 성실히 응하지만 먼저 다가오지 않는다.';
  } else if (score <= 60) {
    return '호의적. 플레이어에게 은근히 관심을 보인다. 가벼운 스킨십에 거부감 없다.';
  } else if (score <= 75) {
    return '뚜렷한 호감. 플레이어 근처에 있고 싶어한다. 진심 어린 대화를 원한다.';
  } else if (score <= 90) {
    return '연애 감정. 플레이어를 매우 의식하고 표현이 직접적. 스킨십을 주도하기도 한다.';
  } else {
    return '깊은 유대. 플레이어에게 완전히 빠져 있다. 감정 표현이 강렬하고 강한 독점욕.';
  }
}

7단계 설계에는 의도가 있다.

  • 15점 단위의 균등 구간: 호감도 10점 차이가 체감되도록 구간을 세분화했다. 55점과 75점의 캐릭터 반응은 완전히 달라야 한다.
  • 점진적 변화: 경계 → 거리두기 → 중립 → 호의 → 호감 → 연애 → 유대. 급격한 태도 변화 없이 자연스러운 관계 발전을 유도한다.
  • 기본값 30점: 스토리 시작 시 호감도는 30(쿨한 거리두기)에서 출발한다. 적대적이지도, 호의적이지도 않은 — 첫 만남에 어울리는 온도다.

이 가이드는 별도의 말투 그라데이션(SPEECH_STYLE_GRADIENT)과 함께 주입된다. 030점에서는 공손한 존대말과 짧은 답변, 61100점에서는 친밀한 말투와 애칭, 적극적 감정 표현이 나온다. 숫자가 아닌 태도로 관계를 표현하는 것이 핵심이다.


핵심 2: AI 호감도 평가 — 매 턴마다의 관계 심판

매 대화 턴이 끝나면, 경량 AI 모델(Claude Haiku)이 호감도 변화를 평가한다. 핵심 원칙은 **"대부분의 상호작용에서 호감도는 변하지 않는다"**는 것이다.

// affinity-evaluation.service.ts — 평가 실행부
async evaluate(context: EvaluationContext): Promise<AffinityChange[]> {
  const systemPrompt = this.buildPrompt(context);

  const response = await this.aiClient.generateText({
    model: 'claude-haiku-4-5',
    system: systemPrompt,
    userMessage: `플레이어 발화: "${context.userMessage}"\n\nAI 생성 응답:\n${context.aiResponse}`,
    maxTokens: 2048,
    responseFormat: 'json',
  });

  const rawChanges = this.parseResult(response.text, context.affinities);
  // 프로그래밍 방식 감쇠 적용
  return this.dampenChanges(rawChanges, context.recentChanges ?? []);
}

AI에게 전달되는 프롬프트에는 여러 겹의 제약이 설정되어 있다. 일반 대화의 변동 범위는 -8 ~ +8이고, 실제로 긍정적 변화는 +1~2가 적절하다. +8은 극히 예외적인 상황에서만 발생한다.

체감 수익 법칙 (Diminishing Returns)

같은 유형의 행동을 반복하면 효과가 급격히 감소한다. 이것은 프롬프트 레벨과 코드 레벨, 두 곳에서 이중으로 적용된다.

프롬프트 레벨 — AI에게 최근 변화 이력과 연속 상승 경고를 전달한다.

// affinity-evaluation.service.ts — 연속 상승 경고 생성
const warnings: string[] = [];
for (const [name, count] of consecutivePositive) {
  if (count >= 4) {
    warnings.push(`${name}: ${count}턴 연속 상승 → 반드시 변화 없음(0) 반환`);
  } else if (count >= 3) {
    warnings.push(`${name}: ${count}턴 연속 상승 → 최대 +1만 가능`);
  } else if (count >= 2) {
    warnings.push(`${name}: ${count}턴 연속 상승 → 최대 +2만 가능`);
  }
}

코드 레벨 — AI가 경고를 무시하더라도, dampenChanges()가 프로그래밍 방식으로 감쇠를 적용한다.

// affinity-evaluation.service.ts — 프로그래밍 감쇠
private dampenChanges(changes: AffinityChange[], recentChanges: RecentChange[]): AffinityChange[] {
  return changes.map((change) => {
    if (change.delta === 0) return change;

    // 같은 방향의 최근 누적 변경량 합산
    const recentSameDir = recentChanges
      .filter((rc) => rc.characterName === change.characterName
        && Math.sign(rc.delta) === Math.sign(change.delta))
      .reduce((sum, rc) => sum + Math.abs(rc.delta), 0);

    // 누적량 * 7%만큼 감쇠 (누적 15점이면 약 100% 감쇠)
    const dampingFactor = Math.max(0, 1 - recentSameDir * 0.07);
    return { ...change, delta: Math.round(change.delta * dampingFactor) };
  }).filter((c) => c.delta !== 0);
}

감쇠 계수 0.07은 "같은 방향으로 약 14점 이상 누적되면 추가 변동이 0에 수렴한다"는 뜻이다. 칭찬만 반복해서 호감도를 올리는 것은 불가능하다. 새로운 유형의 교류 — 비밀 공유, 갈등 해결, 과거 이야기 — 가 필요하다.

호감도 체감 감쇠 곡선 — dampingFactor가 누적 변경량에 따라 1.0에서 0.0으로 감소

호감도가 변하지 않는 경우

의외로 중요한 것은 "변하지 않는 경우"의 목록이다. 일상적인 질문, 게임 진행 발언, 자기소개, 일반 인사 — 이 모든 것에서 호감도는 0이다. 성적/선정적 대화는 절대 긍정적 호감도 변화를 유발하지 않는다 (0 또는 음수만 가능). 이 제약이 없으면 AI는 자극적인 대화에 쉽게 보상을 부여하고, 호감도 시스템 전체가 무력화된다.


핵심 3: 이벤트 상태머신 — 호감도가 스토리를 분기시킨다

호감도가 단순한 수치에 머물지 않으려면, 특정 호감도에 도달했을 때 이벤트가 트리거되어야 한다.

이벤트는 두 가지 방식으로 동작한다.

trigger_type설명예시
sequential메시지 수 도달 시 무조건 진행"10번째 메시지 후 자기소개 시간"
conditional호감도/키워드/플래그 조건 충족 시 발생"호감도 50 이상일 때 옥상 대화"

에피소드의 이벤트 흐름은 이렇게 구성된다.

EP.1 "첫 만남, 그리고 편지"
│
├── [sequential] 오프닝 나레이션 (에피소드 시작 즉시)
│
├── (자유 대화 흐름)
│
├── [sequential, after_messages: 10] "자기소개 시간"
│
├── [conditional, affinity_gte: 20] "옥상 대화"
│
├── [sequential, after_messages: 30] "편지 공개 미션"
│
└── [conditional, story_flag + affinity_gte: 25] 히든: "한시우의 진심"

sequential 이벤트가 플롯의 골격을, conditional 이벤트가 분기의 가지를 형성한다.

분기 조건 평가

이벤트 분기는 postPipelineUpdate()에서 평가된다. plotDirections.keyEvents에 정의된 분기 조건을 현재 호감도와 대조하여 다음 이벤트를 결정한다.

// chat.service.ts — 분기 조건 평가
for (const branch of currentEvent.branches) {
  if (
    branch.condition.type === 'affinity_gte' &&
    branch.condition.character
  ) {
    const score = affinityByName.get(branch.condition.character) ?? 50;
    if (score >= (branch.condition.threshold ?? 0)) {
      nextId = branch.next;
      break;
    }
  } else if (branch.condition.type === 'default') {
    nextId = branch.next;
    break;
  }
}

분기 조건은 배열로 순회되며, 먼저 매칭되는 조건이 우선한다. 호감도 조건을 높은 순으로 배치하면 "호감도 80 이상이면 A, 50 이상이면 B, 나머지는 C"와 같은 tiered 분기가 자연스럽게 구현된다. 마지막에 default 조건을 두면 모든 경로가 커버된다.

DB 스키마

이벤트의 조건과 결과는 모두 JSON 컬럼으로 저장된다. 조건 유형이 확장될 때 스키마 마이그레이션 없이 대응할 수 있다.

// schema.prisma — Event 모델
model Event {
  id               String      @id @default(uuid())
  episodeId        String      @map("episode_id")
  eventType        EventType   // STORY_BRANCH | MISSION | SECRET_SCENE | CG_SCENE | GROUP_EVENT
  triggerType      TriggerType // sequential | conditional
  title            String

  // 조건: JSON으로 유연한 구조
  triggerCondition Json?       @map("trigger_condition")
  // { type: 'affinity_gte', character_id, value }
  // { type: 'combined', additional_conditions: [...] }

  // 결과: 호감도 변화(이벤트 전용 -20~+20), 감정 전환, 플래그
  choices          Json?       // [{ text, outcome: { affinity_change, story_flag } }]
  outcome          Json?       // 선택지 없는 이벤트의 기본 결과
}

일반 대화의 호감도 변동이 -8~+8인 것과 달리, 이벤트에서는 -20~+20까지 허용된다. 핵심 분기점에서의 선택이 관계에 극적인 영향을 미치도록 설계한 것이다.


핵심 4: 신체 접촉 규칙 — 호감도의 물리적 경계

호감도 시스템에서 가장 민감한 영역은 신체 접촉이다. "호감도 30인 캐릭터가 키스를 수용한다"면, 시스템 전체의 신뢰성이 무너진다.

PHYSICAL_CONTACT_RULES는 호감도 구간별로 허용되는 신체 접촉의 범위를 명확히 정의한다.

// game-rules.ts — 호감도별 신체 접촉 허용 범위
export const PHYSICAL_CONTACT_RULES = `[호감도별 신체 접촉 허용 범위 — 절대 규칙]
- 0~15점: 모든 신체 접촉 거부/회피.
- 16~30점: 악수 정도만. 그 이상은 당황하며 거리를 둔다.
- 31~45점: 악수, 어깨 가볍게 치기. 그 이상은 부자연스러움.
- 46~60점: 팔짱, 가벼운 스킨십 수용. 키스 시도 시 놀라며 피함.
- 61~75점: 손잡기, 가벼운 포옹 자연스러움. 키스 분위기에 따라 수용.
- 76~90점: 키스, 포옹, 적극적 스킨십 수용/주도.
- 91~100점: 강한 독점욕과 열정. 성적 접촉에 매우 적극적.`;

핵심은 거부 묘사의 구체성이다. 허용 범위를 벗어나는 접촉을 시도하면, 캐릭터는 "밀어낸다", "한 발 물러선다", "손을 잡아 내린다", "표정이 굳는다" 등 구체적인 거부 행동으로 반응한다. 성격에 따라 거부 방식도 다르다 — 직접적 거절, 유머로 넘기기, 침묵으로 거리두기.

이 규칙은 호감도 평가와도 연동된다. 호감도 45 미만에서 과도한 신체 접촉을 시도하면 호감도가 -1~-3 하락한다. 60점 이상에서도 신체적 친밀함만으로는 호감도가 오르지 않는다. 감정적 교류 없는 물리적 접촉은 관계를 발전시키지 못한다.

호감도 구간별 신체 접촉 허용 범위 — 014 적대에서 90100 헌신까지 7단계


핵심 5: 되감기(Time Leap) — 스냅샷 기반 상태 복원

선택을 잘못했다면? 호감도가 떨어져서 핵심 이벤트를 놓쳤다면? 이것이 되감기(Time Leap) 기능의 존재 이유다.

되감기의 핵심은 **affinitySnapshot**이다. 매 사용자 메시지마다, 그 시점의 모든 캐릭터 호감도를 ChatMessage에 JSON으로 기록한다.

// chat.controller.ts — 메시지 전송 시 스냅샷 캡처
const affinitySnapshot: Record<string, number | string | boolean> = {};
for (const a of ctx.session.affinityScores) {
  affinitySnapshot[a.characterId] = a.score;
}
// 이벤트 상태도 기록
affinitySnapshot.__eventId = ctx.session.stateJson.currentEventId ?? 'intro';

되감기 요청이 들어오면, 해당 메시지의 스냅샷을 읽어 상태를 복원한다.

// chat.controller.ts — 되감기 엔드포인트 (DELETE /:messageId/rewind)
const snapshot = message.affinitySnapshot as Record<string, number | string> | null;
const restoredEventId = snapshot?.__eventId as string | undefined;

// 1. 해당 시점 이후의 모든 메시지 삭제
await this.prisma.chatMessage.deleteMany({
  where: { sessionId, createdAt: { gte: message.createdAt } },
});

// 2. 범위가 겹치는 기억(메모리)도 삭제
await this.prisma.sessionMemory.deleteMany({
  where: { sessionId, messageRangeEnd: { gte: message.createdAt } },
});

// 3. 호감도 복원 (__eventId, __isTalk 등 메타 키는 스킵)
if (snapshot) {
  for (const [key, score] of Object.entries(snapshot)) {
    if (key.startsWith('__')) continue;
    await this.prisma.affinityScore.updateMany({
      where: { sessionId, characterId: key },
      data: { score: score as number },
    });
  }
}

되감기가 복원하는 것은 세 가지다: 호감도, 이벤트 상태, 메시지 이력. 그리고 되감기 시점 이후에 생성된 기억(SessionMemory)도 삭제한다. AI가 "미래"의 대화를 기억하는 모순을 방지하기 위해서다.

되감기는 행동력 2를 소비한다. 무한 되감기를 방지하면서도, 핵심 분기점에서 "다른 선택을 해보고 싶다"는 동기를 자연스럽게 수용하는 비용이다.


정리

호감도 시스템을 설계하며 적용한 핵심 설계 원칙을 정리한다.

  1. AI에게 제약을 두 번 걸어라. 프롬프트만으로는 충분하지 않다. 체감 수익 법칙은 프롬프트 경고와 코드 감쇠를 이중으로 적용했고, 신체 접촉 규칙은 행동 가이드와 호감도 평가를 교차 검증한다. LLM은 지침을 무시할 수 있다 — 코드는 무시할 수 없다.

  2. "변하지 않는 경우"를 먼저 정의하라. 호감도 변화의 기본값은 0이다. 대부분의 대화에서 호감도가 변하지 않아야, 변할 때 의미가 생긴다. 무조건 +1이라도 주는 시스템은 관계 역학이 아니라 경험치 시스템이 된다.

  3. 감쇠 계수는 의외로 중요하다. 0.07이라는 단일 상수가 "같은 행동 반복의 비용"을 결정한다. 이 값이 너무 낮으면 칭찬만 반복해도 호감도가 올라가고, 너무 높으면 정상적인 대화에서도 변동이 사라진다. 플레이테스트를 통한 반복 조정이 필수다.

  4. 스냅샷은 복원의 전제 조건이다. 되감기를 구현하려면 "되돌아갈 시점의 상태"가 필요하다. ChatMessage에 affinitySnapshot을 매번 기록하는 것은 저장 비용이 들지만, 이것 없이는 되감기가 불가능하다. 기억(SessionMemory) 삭제까지 포함해야 시간 일관성이 유지된다.

  5. 이벤트의 분기 조건은 배열 순서가 우선순위다. affinity_gte: 80affinity_gte: 50default 순서로 배치하면 tiered 분기가 자연스럽게 구현된다. 복잡한 우선순위 로직 대신, 배열의 first-match 규칙을 활용한 단순한 설계다.

  6. 물리적 접촉의 경계는 콘텐츠 신뢰성의 기초다. 호감도 30에서 키스를 수용하는 캐릭터는 호감도 시스템 자체를 무력화한다. 허용 범위를 넘는 접촉에 대한 구체적인 거부 묘사가 캐릭터의 일관성을 지킨다.